Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d28b5745a | |||
| affe745250 | |||
| cb96343d8c | |||
| 56af7a3c60 | |||
| 95856800de | |||
| b1b0c798b3 | |||
| cffe934d5e | |||
| 3c7aec1573 |
+1
-1
@@ -7,7 +7,7 @@
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Daagbox" />
|
||||
<meta name="apple-mobile-web-app-title" content="Daagbok" />
|
||||
<meta name="theme-color" content="#1e293b" />
|
||||
<link rel="apple-touch-icon" href="/logo.png" />
|
||||
<title>Kapteins Daagbok</title>
|
||||
|
||||
+231
-13
@@ -1,4 +1,4 @@
|
||||
/* Kapteins Daagbox App styling */
|
||||
/* Kapteins Daagbok App styling */
|
||||
|
||||
body {
|
||||
background: radial-gradient(circle at center, #1b264f 0%, #0b0c10 100%);
|
||||
@@ -55,7 +55,8 @@ body {
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
margin: 0 0 8px 0;
|
||||
margin: 0 0 14px 0;
|
||||
line-height: 1.25;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
@@ -63,7 +64,7 @@ body {
|
||||
font-size: 14.5px;
|
||||
color: #94a3b8;
|
||||
margin: 0;
|
||||
line-height: 140%;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
@@ -449,6 +450,51 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-account-section {
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
margin: 40px auto 48px;
|
||||
padding-bottom: calc(32px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.account-danger-zone {
|
||||
border-top: 1px solid rgba(239, 68, 68, 0.2);
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
.account-danger-zone__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.account-danger-zone__icon {
|
||||
color: #ef4444;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.account-danger-zone__title {
|
||||
margin: 0;
|
||||
color: #ef4444;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.account-danger-zone__desc {
|
||||
font-size: 13.5px;
|
||||
color: #94a3b8;
|
||||
line-height: 1.45;
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
.account-danger-zone__actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.account-danger-zone__actions .btn {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.create-section {
|
||||
background: rgba(11, 12, 16, 0.6);
|
||||
backdrop-filter: blur(20px);
|
||||
@@ -1518,6 +1564,7 @@ body:has(.theme-cupertino) {
|
||||
color: #e2e8f0;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 24px 0;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.custom-dialog-actions {
|
||||
@@ -1845,6 +1892,41 @@ body:has(.theme-cupertino) {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.consumption-grid .input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.consumption-grid .input-group label {
|
||||
flex: 1 1 auto;
|
||||
min-height: 2.75em;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.consumption-grid .input-group .input-text {
|
||||
flex-shrink: 0;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
.consumption-grid .input-text::-webkit-outer-spin-button,
|
||||
.consumption-grid .input-text::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.consumption-grid .consumption-value {
|
||||
color: #4ade80;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
@@ -1894,16 +1976,6 @@ body:has(.theme-cupertino) {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
#openseamap-container {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(212, 175, 55, 0.2);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.track-info-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -1944,6 +2016,152 @@ body:has(.theme-cupertino) {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.track-stats-grid {
|
||||
margin-top: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.track-map-wrapper {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
#openseamap-container,
|
||||
.track-map-container {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(212, 175, 55, 0.2);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
.track-map-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.track-map-legend-bar {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(to right, hsl(120, 72%, 42%), hsl(60, 72%, 42%), hsl(0, 72%, 42%));
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.track-map-container.leaflet-container {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.track-map-container .leaflet-tile,
|
||||
.track-map-container img.leaflet-tile {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
.track-map-container .leaflet-control-attribution {
|
||||
font-size: 10px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.signature-grid {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.signature-pad-group {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.signature-pad-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.signature-pad-header label {
|
||||
display: block;
|
||||
font-size: 13.5px;
|
||||
color: #94a3b8;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.signature-pad-clear {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.signature-pad-clear:hover {
|
||||
color: #e2e8f0;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.signature-pad {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 132px;
|
||||
border-radius: 10px;
|
||||
border: 1px dashed rgba(148, 163, 184, 0.45);
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.signature-pad.disabled {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.signature-pad-canvas,
|
||||
.signature-pad-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.signature-pad-canvas {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.signature-pad-hint {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.signature-legacy-text {
|
||||
min-height: 132px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: #e2e8f0;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* PWA install prompt */
|
||||
.pwa-install-banner {
|
||||
position: fixed;
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Trash2, AlertTriangle } from 'lucide-react'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import { deleteAccount } from '../services/auth.js'
|
||||
|
||||
interface AccountDangerZoneProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function AccountDangerZone({ className = '' }: AccountDangerZoneProps) {
|
||||
const { t } = useTranslation()
|
||||
const { showConfirm, showAlert } = useDialog()
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
const handleDeleteAccount = async () => {
|
||||
const confirmed = await showConfirm(
|
||||
t('settings.delete_account_confirm_desc'),
|
||||
t('settings.delete_account_confirm_title'),
|
||||
t('settings.delete_account_confirm_yes'),
|
||||
t('settings.delete_account_confirm_no')
|
||||
)
|
||||
|
||||
if (!confirmed) return
|
||||
|
||||
setDeleting(true)
|
||||
try {
|
||||
const success = await deleteAccount()
|
||||
if (success) {
|
||||
window.location.reload()
|
||||
} else {
|
||||
showAlert(t('settings.delete_account_failed'))
|
||||
}
|
||||
} catch (err: any) {
|
||||
showAlert(err.message || t('settings.delete_account_failed'))
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`account-danger-zone member-editor-card glass ${className}`.trim()}>
|
||||
<div className="account-danger-zone__header">
|
||||
<AlertTriangle size={20} className="account-danger-zone__icon" />
|
||||
<h3 className="account-danger-zone__title">{t('settings.danger_zone_title')}</h3>
|
||||
</div>
|
||||
|
||||
<p className="account-danger-zone__desc">{t('settings.danger_zone_desc')}</p>
|
||||
|
||||
<div className="form-actions account-danger-zone__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn danger"
|
||||
onClick={handleDeleteAccount}
|
||||
disabled={deleting}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
{deleting ? t('settings.deleting_account') : t('settings.delete_account_btn')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -382,7 +382,7 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
|
||||
return (
|
||||
<div className="auth-card glass">
|
||||
<div className="auth-brand">
|
||||
<img src="/logo.png" alt="Kapteins Daagbox" className="auth-logo-img" />
|
||||
<img src="/logo.png" alt="Kapteins Daagbok" className="auth-logo-img" />
|
||||
<h1>{t('app.name')}</h1>
|
||||
<p className="tagline">{t('auth.tagline')}</p>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,15 @@ import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
||||
import LogEntryEditor from './LogEntryEditor.tsx'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2 } from 'lucide-react'
|
||||
import {
|
||||
carryOverTankLevelsFromPreviousDay,
|
||||
compareTravelDaysChronological,
|
||||
emptyTankLevels,
|
||||
formatTankLiters,
|
||||
getNextTravelDayNumber,
|
||||
type LogEntryTankSource,
|
||||
type TravelDaySortable
|
||||
} from '../utils/logEntryTankLevels.js'
|
||||
|
||||
interface LogEntriesListProps {
|
||||
logbookId: string
|
||||
@@ -179,26 +188,52 @@ export default function LogEntriesList({
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (readOnly) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
|
||||
const localEntries = await db.entries.where({ logbookId }).toArray()
|
||||
const decryptedEntries: Array<LogEntryTankSource & TravelDaySortable> = []
|
||||
|
||||
for (const entry of localEntries) {
|
||||
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
|
||||
if (decrypted) decryptedEntries.push(decrypted as LogEntryTankSource & TravelDaySortable)
|
||||
}
|
||||
|
||||
decryptedEntries.sort(compareTravelDaysChronological)
|
||||
const previousEntry = decryptedEntries.at(-1) ?? null
|
||||
let { freshwater, fuel } = carryOverTankLevelsFromPreviousDay(previousEntry)
|
||||
|
||||
if (previousEntry && (freshwater.morning > 0 || fuel.morning > 0)) {
|
||||
const confirmed = await showConfirm(
|
||||
t('logs.carry_over_tanks_confirm', {
|
||||
fw: formatTankLiters(freshwater.morning),
|
||||
fuel: formatTankLiters(fuel.morning)
|
||||
}),
|
||||
t('logs.carry_over_tanks_title'),
|
||||
t('logs.carry_over_tanks_yes'),
|
||||
t('logs.carry_over_tanks_no')
|
||||
)
|
||||
if (!confirmed) {
|
||||
freshwater = emptyTankLevels()
|
||||
fuel = emptyTankLevels()
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
const localId = window.crypto.randomUUID()
|
||||
const nowStr = new Date().toISOString()
|
||||
const todayStr = nowStr.substring(0, 10)
|
||||
|
||||
// Calculate next travel day number
|
||||
const nextDayNum = String(entries.length + 1)
|
||||
|
||||
const initialPayload = {
|
||||
date: todayStr,
|
||||
dayOfTravel: nextDayNum,
|
||||
dayOfTravel: getNextTravelDayNumber(decryptedEntries),
|
||||
departure: '',
|
||||
destination: '',
|
||||
freshwater: { morning: 0, refilled: 0, evening: 0, consumption: 0 },
|
||||
fuel: { morning: 0, refilled: 0, evening: 0, consumption: 0 },
|
||||
freshwater,
|
||||
fuel,
|
||||
signSkipper: '',
|
||||
signCrew: '',
|
||||
events: []
|
||||
@@ -275,7 +310,7 @@ export default function LogEntriesList({
|
||||
readOnly={readOnly}
|
||||
preloadedEntry={preloadedEntries?.find(entry => (entry.payloadId || entry.id) === selectedEntryId)}
|
||||
preloadedPhotos={preloadedPhotos}
|
||||
preloadedGpsTrack={preloadedGpsTracks?.find(track => track.entryId === selectedEntryId)}
|
||||
preloadedTrack={preloadedGpsTracks?.find(track => track.entryId === selectedEntryId)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,20 +6,21 @@ import { getLogbookKey } from '../services/logbookKeys.js'
|
||||
import { encryptJson, decryptJson } from '../services/crypto.js'
|
||||
import { syncLogbook } from '../services/sync.js'
|
||||
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
||||
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Navigation } from 'lucide-react'
|
||||
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload } from 'lucide-react'
|
||||
import PhotoCapture from './PhotoCapture.tsx'
|
||||
import SignaturePad from './SignaturePad.tsx'
|
||||
import TrackMap from './TrackMap.tsx'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import L from 'leaflet'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import { isSignatureImage } from '../utils/signatures.js'
|
||||
import {
|
||||
getDecryptedGpsTrack,
|
||||
saveUploadedGpsTrack,
|
||||
deleteGpsTrack,
|
||||
getDecryptedTrack,
|
||||
saveUploadedTrack,
|
||||
deleteTrack,
|
||||
downloadTrackFile,
|
||||
parseTrackFile,
|
||||
type GpsWaypoint,
|
||||
type SavedGpsTrack
|
||||
} from '../services/gpsTracker.js'
|
||||
type SavedTrack
|
||||
} from '../services/trackUpload.js'
|
||||
import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
|
||||
|
||||
interface LogEntryEditorProps {
|
||||
entryId: string
|
||||
@@ -28,7 +29,7 @@ interface LogEntryEditorProps {
|
||||
readOnly?: boolean
|
||||
preloadedEntry?: any
|
||||
preloadedPhotos?: any[]
|
||||
preloadedGpsTrack?: any
|
||||
preloadedTrack?: any
|
||||
preloadedYacht?: any
|
||||
}
|
||||
|
||||
@@ -58,7 +59,7 @@ export default function LogEntryEditor({
|
||||
readOnly = false,
|
||||
preloadedEntry,
|
||||
preloadedPhotos,
|
||||
preloadedGpsTrack,
|
||||
preloadedTrack,
|
||||
preloadedYacht
|
||||
}: LogEntryEditorProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
@@ -87,6 +88,11 @@ export default function LogEntryEditor({
|
||||
const [signSkipper, setSignSkipper] = useState('')
|
||||
const [signCrew, setSignCrew] = useState('')
|
||||
|
||||
// GPS track stats (from uploaded track)
|
||||
const [trackDistanceNm, setTrackDistanceNm] = useState('')
|
||||
const [trackSpeedMaxKn, setTrackSpeedMaxKn] = useState('')
|
||||
const [trackSpeedAvgKn, setTrackSpeedAvgKn] = useState('')
|
||||
|
||||
// Events list state
|
||||
const [events, setEvents] = useState<LogEvent[]>([])
|
||||
|
||||
@@ -116,14 +122,32 @@ export default function LogEntryEditor({
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [weatherLoading, setWeatherLoading] = useState(false)
|
||||
|
||||
// GPS Tracking States
|
||||
const [savedTrack, setSavedTrack] = useState<SavedGpsTrack | null>(null)
|
||||
// Track file upload
|
||||
const [savedTrack, setSavedTrack] = useState<SavedTrack | null>(null)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const [uploadError, setUploadError] = useState<string | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const mapContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
const mapInstanceRef = useRef<L.Map | null>(null)
|
||||
const applyTrackStats = (waypoints: SavedTrack['waypoints']) => {
|
||||
const stats = computeTrackStats(waypoints)
|
||||
if (!stats) return
|
||||
const formatted = formatTrackStats(stats)
|
||||
setTrackDistanceNm(formatted.distanceNm)
|
||||
setTrackSpeedMaxKn(formatted.speedMaxKn)
|
||||
setTrackSpeedAvgKn(formatted.speedAvgKn)
|
||||
}
|
||||
|
||||
const loadTrackStatsFromEntry = (entry: any) => {
|
||||
if (entry?.trackDistanceNm != null && entry.trackDistanceNm !== '') {
|
||||
setTrackDistanceNm(String(entry.trackDistanceNm))
|
||||
}
|
||||
if (entry?.trackSpeedMaxKn != null && entry.trackSpeedMaxKn !== '') {
|
||||
setTrackSpeedMaxKn(String(entry.trackSpeedMaxKn))
|
||||
}
|
||||
if (entry?.trackSpeedAvgKn != null && entry.trackSpeedAvgKn !== '') {
|
||||
setTrackSpeedAvgKn(String(entry.trackSpeedAvgKn))
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-calculate Freshwater Consumption
|
||||
useEffect(() => {
|
||||
@@ -193,6 +217,7 @@ export default function LogEntryEditor({
|
||||
|
||||
setSignSkipper(preloadedEntry.signSkipper || '')
|
||||
setSignCrew(preloadedEntry.signCrew || '')
|
||||
loadTrackStatsFromEntry(preloadedEntry)
|
||||
setEvents(preloadedEntry.events || [])
|
||||
return
|
||||
}
|
||||
@@ -222,6 +247,7 @@ export default function LogEntryEditor({
|
||||
|
||||
setSignSkipper(decrypted.signSkipper || '')
|
||||
setSignCrew(decrypted.signCrew || '')
|
||||
loadTrackStatsFromEntry(decrypted)
|
||||
setEvents(decrypted.events || [])
|
||||
}
|
||||
}
|
||||
@@ -236,91 +262,30 @@ export default function LogEntryEditor({
|
||||
loadEntry()
|
||||
}, [entryId, preloadedEntry])
|
||||
|
||||
// GPS Track Loader
|
||||
const loadGpsTrack = async () => {
|
||||
if (readOnly && preloadedGpsTrack) {
|
||||
setSavedTrack(preloadedGpsTrack)
|
||||
const loadTrack = async () => {
|
||||
if (readOnly && preloadedTrack) {
|
||||
setSavedTrack(preloadedTrack)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const track = await getDecryptedGpsTrack(entryId)
|
||||
const track = await getDecryptedTrack(entryId)
|
||||
setSavedTrack(track)
|
||||
} catch (e) {
|
||||
console.warn('Failed to load GPS track:', e)
|
||||
console.warn('Failed to load track file:', e)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadGpsTrack()
|
||||
}, [entryId, preloadedGpsTrack])
|
||||
loadTrack()
|
||||
}, [entryId, preloadedTrack])
|
||||
|
||||
// Leaflet Map Initialization and Rendering
|
||||
useEffect(() => {
|
||||
if (!savedTrack || !savedTrack.waypoints || savedTrack.waypoints.length === 0 || !mapContainerRef.current) {
|
||||
if (mapInstanceRef.current) {
|
||||
mapInstanceRef.current.remove()
|
||||
mapInstanceRef.current = null
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!savedTrack || savedTrack.waypoints.length < 2) return
|
||||
if (trackDistanceNm || trackSpeedMaxKn || trackSpeedAvgKn) return
|
||||
applyTrackStats(savedTrack.waypoints)
|
||||
}, [savedTrack, trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn])
|
||||
|
||||
const startWp = savedTrack.waypoints[0]
|
||||
const map = L.map(mapContainerRef.current).setView([startWp.lat, startWp.lng], 13)
|
||||
mapInstanceRef.current = map
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: '© <a href="https://openstreetmap.org">OpenStreetMap</a> contributors'
|
||||
}).addTo(map)
|
||||
|
||||
L.tileLayer('https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png', {
|
||||
maxZoom: 18,
|
||||
attribution: 'Map data © <a href="http://openseamap.org">OpenSeaMap</a> contributors'
|
||||
}).addTo(map)
|
||||
|
||||
const latLngs = savedTrack.waypoints.map((wp) => [wp.lat, wp.lng] as [number, number])
|
||||
|
||||
const polyline = L.polyline(latLngs, {
|
||||
color: '#fbbf24',
|
||||
weight: 4,
|
||||
opacity: 0.85
|
||||
}).addTo(map)
|
||||
|
||||
map.fitBounds(polyline.getBounds(), { padding: [20, 20] })
|
||||
|
||||
if (savedTrack.waypoints.length > 0) {
|
||||
L.circleMarker(latLngs[0], {
|
||||
radius: 8,
|
||||
fillColor: '#10b981',
|
||||
fillOpacity: 0.9,
|
||||
color: '#ffffff',
|
||||
weight: 2
|
||||
}).addTo(map).bindPopup('Start Position')
|
||||
|
||||
if (savedTrack.waypoints.length > 1) {
|
||||
L.circleMarker(latLngs[latLngs.length - 1], {
|
||||
radius: 8,
|
||||
fillColor: '#ef4444',
|
||||
fillOpacity: 0.9,
|
||||
color: '#ffffff',
|
||||
weight: 2
|
||||
}).addTo(map).bindPopup('End Position')
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
map.invalidateSize()
|
||||
}, 100)
|
||||
|
||||
return () => {
|
||||
if (mapInstanceRef.current) {
|
||||
mapInstanceRef.current.remove()
|
||||
mapInstanceRef.current = null
|
||||
}
|
||||
}
|
||||
}, [savedTrack])
|
||||
|
||||
// GPX/KML/GeoJSON Upload Handlers
|
||||
// Track file upload handlers
|
||||
const handleFileUpload = async (file: File) => {
|
||||
if (readOnly) return
|
||||
setUploadError(null)
|
||||
@@ -338,8 +303,9 @@ export default function LogEntryEditor({
|
||||
throw new Error('No coordinates found in file. Supported formats: GPX, KML, GeoJSON.')
|
||||
}
|
||||
|
||||
await saveUploadedGpsTrack(logbookId, entryId, text, parsedWps, file.name, fileType)
|
||||
await loadGpsTrack()
|
||||
await saveUploadedTrack(logbookId, entryId, text, parsedWps, file.name, fileType)
|
||||
applyTrackStats(parsedWps)
|
||||
await loadTrack()
|
||||
} catch (err: any) {
|
||||
console.error('File parsing failed:', err)
|
||||
setUploadError(err.message || 'Failed to parse track file.')
|
||||
@@ -380,38 +346,17 @@ export default function LogEntryEditor({
|
||||
return
|
||||
}
|
||||
try {
|
||||
await deleteGpsTrack(logbookId, entryId)
|
||||
await deleteTrack(logbookId, entryId)
|
||||
setSavedTrack(null)
|
||||
setTrackDistanceNm('')
|
||||
setTrackSpeedMaxKn('')
|
||||
setTrackSpeedAvgKn('')
|
||||
setUploadError(null)
|
||||
} catch (err: any) {
|
||||
showAlert(err.message || 'Failed to delete track')
|
||||
}
|
||||
}
|
||||
|
||||
const calculateTrackDistance = (wps: GpsWaypoint[]) => {
|
||||
if (wps.length < 2) return 0
|
||||
let totalMeters = 0
|
||||
for (let i = 1; i < wps.length; i++) {
|
||||
const lat1 = wps[i - 1].lat
|
||||
const lon1 = wps[i - 1].lng
|
||||
const lat2 = wps[i].lat
|
||||
const lon2 = wps[i].lng
|
||||
|
||||
const R = 6371e3
|
||||
const phi1 = (lat1 * Math.PI) / 180
|
||||
const phi2 = (lat2 * Math.PI) / 180
|
||||
const deltaPhi = ((lat2 - lat1) * Math.PI) / 180
|
||||
const deltaLambda = ((lon2 - lon1) * Math.PI) / 180
|
||||
|
||||
const a =
|
||||
Math.sin(deltaPhi / 2) * Math.sin(deltaPhi / 2) +
|
||||
Math.cos(phi1) * Math.cos(phi2) * Math.sin(deltaLambda / 2) * Math.sin(deltaLambda / 2)
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
|
||||
totalMeters += R * c
|
||||
}
|
||||
return Number((totalMeters / 1852).toFixed(2))
|
||||
}
|
||||
|
||||
const handleGetGps = () => {
|
||||
if (readOnly) return
|
||||
const lookupFallback = async () => {
|
||||
@@ -663,8 +608,11 @@ export default function LogEntryEditor({
|
||||
evening: parseFloat(fuelEvening) || 0,
|
||||
consumption: parseFloat(fuelConsumption) || 0
|
||||
},
|
||||
signSkipper: signSkipper.trim(),
|
||||
signCrew: signCrew.trim(),
|
||||
signSkipper: isSignatureImage(signSkipper) ? signSkipper : signSkipper.trim(),
|
||||
signCrew: isSignatureImage(signCrew) ? signCrew : signCrew.trim(),
|
||||
trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined,
|
||||
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
|
||||
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
|
||||
events
|
||||
}
|
||||
|
||||
@@ -816,7 +764,7 @@ export default function LogEntryEditor({
|
||||
</div>
|
||||
<div className="consumption-grid">
|
||||
<div className="input-group">
|
||||
<label>{t('logs.freshwater')} ({t('logs.morning')})</label>
|
||||
<label>{t('logs.morning')}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-text"
|
||||
@@ -827,7 +775,7 @@ export default function LogEntryEditor({
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.freshwater')} ({t('logs.refilled')})</label>
|
||||
<label>{t('logs.refilled')}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-text"
|
||||
@@ -838,7 +786,7 @@ export default function LogEntryEditor({
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.freshwater')} ({t('logs.evening')})</label>
|
||||
<label>{t('logs.evening')}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-text"
|
||||
@@ -851,11 +799,12 @@ export default function LogEntryEditor({
|
||||
<div className="input-group">
|
||||
<label>{t('logs.consumption')} (L)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text cell-input text-green"
|
||||
style={{ color: '#4ade80', fontWeight: 'bold' }}
|
||||
type="number"
|
||||
className="input-text consumption-value"
|
||||
value={fwConsumption}
|
||||
disabled
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
aria-readonly="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -869,7 +818,7 @@ export default function LogEntryEditor({
|
||||
</div>
|
||||
<div className="consumption-grid">
|
||||
<div className="input-group">
|
||||
<label>{t('logs.fuel')} ({t('logs.morning')})</label>
|
||||
<label>{t('logs.morning')}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-text"
|
||||
@@ -880,7 +829,7 @@ export default function LogEntryEditor({
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.fuel')} ({t('logs.refilled')})</label>
|
||||
<label>{t('logs.refilled')}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-text"
|
||||
@@ -891,7 +840,7 @@ export default function LogEntryEditor({
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.fuel')} ({t('logs.evening')})</label>
|
||||
<label>{t('logs.evening')}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-text"
|
||||
@@ -904,11 +853,12 @@ export default function LogEntryEditor({
|
||||
<div className="input-group">
|
||||
<label>{t('logs.consumption')} (L)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text cell-input text-green"
|
||||
style={{ color: '#4ade80', fontWeight: 'bold' }}
|
||||
type="number"
|
||||
className="input-text consumption-value"
|
||||
value={fuelConsumption}
|
||||
disabled
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
aria-readonly="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1234,17 +1184,16 @@ export default function LogEntryEditor({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* GPS Track Upload & Map Visualization */}
|
||||
{/* Track file upload */}
|
||||
<div className="form-card">
|
||||
<div className="form-header">
|
||||
<Navigation size={20} className="form-icon" />
|
||||
<h3>{t('logs.gps_tracking_title')}</h3>
|
||||
<Upload size={20} className="form-icon" />
|
||||
<h3>{t('logs.track_upload_title')}</h3>
|
||||
</div>
|
||||
|
||||
{uploadError && <div className="track-error-msg">{uploadError}</div>}
|
||||
|
||||
{!savedTrack ? (
|
||||
/* Upload Zone when no track is loaded */
|
||||
<div
|
||||
className={`track-upload-zone ${dragOver ? 'dragover' : ''}`}
|
||||
onDragOver={handleDragOver}
|
||||
@@ -1260,27 +1209,33 @@ export default function LogEntryEditor({
|
||||
onChange={handleFileChange}
|
||||
disabled={saving}
|
||||
/>
|
||||
<Download size={36} className="track-upload-icon" />
|
||||
<Upload size={36} className="track-upload-icon" />
|
||||
<div className="track-upload-text">{t('logs.gps_track_upload_btn')}</div>
|
||||
<div className="track-upload-subtext">{t('logs.gps_track_upload_help')}</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Map and Details when track is loaded */
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<>
|
||||
<div className="track-info-header">
|
||||
<div className="track-info-left">
|
||||
<Navigation size={16} style={{ color: '#fbbf24' }} />
|
||||
<Upload size={16} style={{ color: '#fbbf24' }} />
|
||||
<span className="track-info-name">{savedTrack.filename || 'track'}</span>
|
||||
</div>
|
||||
<div className="track-info-stats">
|
||||
<span style={{ marginRight: '12px' }}>
|
||||
{t('logs.gps_tracking_stat_distance')}: <strong>{calculateTrackDistance(savedTrack.waypoints)} sm</strong>
|
||||
</span>
|
||||
<span>
|
||||
{t('logs.gps_tracking_stat_waypoints')}: <strong>{savedTrack.waypoints.length}</strong>
|
||||
<span className="track-info-stats">
|
||||
{savedTrack.fileType.toUpperCase()}
|
||||
{savedTrack.waypoints.length > 0 && (
|
||||
<> · {savedTrack.waypoints.length} {t('logs.track_upload_points')}</>
|
||||
)}
|
||||
{trackDistanceNm && (
|
||||
<> · {trackDistanceNm} sm</>
|
||||
)}
|
||||
{trackSpeedMaxKn && (
|
||||
<> · max {trackSpeedMaxKn} kn</>
|
||||
)}
|
||||
{trackSpeedAvgKn && (
|
||||
<> · Ø {trackSpeedAvgKn} kn</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
@@ -1304,8 +1259,50 @@ export default function LogEntryEditor({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Leaflet Map Div */}
|
||||
<div id="openseamap-container" ref={mapContainerRef} />
|
||||
{savedTrack.waypoints.length > 0 && (
|
||||
<TrackMap waypoints={savedTrack.waypoints} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{(savedTrack || trackDistanceNm || trackSpeedMaxKn || trackSpeedAvgKn) && (
|
||||
<div className="form-grid track-stats-grid">
|
||||
<div className="input-group">
|
||||
<label>{t('logs.track_distance')}</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
placeholder="e.g. 5.0"
|
||||
className="input-text"
|
||||
value={trackDistanceNm}
|
||||
onChange={(e) => setTrackDistanceNm(e.target.value)}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label>{t('logs.track_speed_max')}</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
placeholder="e.g. 7.8"
|
||||
className="input-text"
|
||||
value={trackSpeedMaxKn}
|
||||
onChange={(e) => setTrackSpeedMaxKn(e.target.value)}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label>{t('logs.track_speed_avg')}</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
placeholder="e.g. 4.6"
|
||||
className="input-text"
|
||||
value={trackSpeedAvgKn}
|
||||
onChange={(e) => setTrackSpeedAvgKn(e.target.value)}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1318,30 +1315,24 @@ export default function LogEntryEditor({
|
||||
<Check size={20} className="form-icon" />
|
||||
<h3>{t('logs.signatures')}</h3>
|
||||
</div>
|
||||
<div className="form-grid">
|
||||
<div className="input-group">
|
||||
<label>{t('logs.sign_skipper')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. MARKUS SKIPPER"
|
||||
className="input-text"
|
||||
value={signSkipper}
|
||||
onChange={(e) => setSignSkipper(e.target.value)}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-grid signature-grid">
|
||||
<SignaturePad
|
||||
id="sign-skipper"
|
||||
label={t('logs.sign_skipper')}
|
||||
value={signSkipper}
|
||||
onChange={setSignSkipper}
|
||||
disabled={saving}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.sign_crew')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. JAN MATE"
|
||||
className="input-text"
|
||||
value={signCrew}
|
||||
onChange={(e) => setSignCrew(e.target.value)}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
<SignaturePad
|
||||
id="sign-crew"
|
||||
label={t('logs.sign_crew')}
|
||||
value={signCrew}
|
||||
onChange={setSignCrew}
|
||||
disabled={saving}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { db } from '../services/db.js'
|
||||
import { fetchLogbooks, createLogbook, deleteLogbook, type DecryptedLogbook } from '../services/logbook.js'
|
||||
import { logoutUser } from '../services/auth.js'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import AccountDangerZone from './AccountDangerZone.tsx'
|
||||
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, User, Wifi, WifiOff } from 'lucide-react'
|
||||
|
||||
interface LogbookDashboardProps {
|
||||
@@ -227,6 +228,10 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<section className="dashboard-account-section" aria-label={t('settings.danger_zone_title')}>
|
||||
<AccountDangerZone />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Settings as SettingsIcon, Save, Check, Users, Trash2, Copy, Link as LinkIcon, AlertTriangle } from 'lucide-react'
|
||||
import { Settings as SettingsIcon, Save, Check, Users, Trash2, Copy, Link as LinkIcon } from 'lucide-react'
|
||||
import { ensureLogbookKey } from '../services/logbookKeys.js'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import AccountDangerZone from './AccountDangerZone.tsx'
|
||||
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
|
||||
import { deleteAccount } from '../services/auth.js'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
|
||||
interface SettingsFormProps {
|
||||
logbookId?: string | null
|
||||
@@ -48,31 +48,6 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
|
||||
const [shareCopied, setShareCopied] = useState(false)
|
||||
const [loadingShareLink, setLoadingShareLink] = useState(false)
|
||||
|
||||
const handleDeleteAccount = async () => {
|
||||
const confirmed = await showConfirm(
|
||||
t('settings.delete_account_confirm_desc'),
|
||||
t('settings.delete_account_confirm_title'),
|
||||
t('settings.delete_account_confirm_yes'),
|
||||
t('settings.delete_account_confirm_no')
|
||||
)
|
||||
|
||||
if (confirmed) {
|
||||
setSaving(true)
|
||||
try {
|
||||
const success = await deleteAccount()
|
||||
if (success) {
|
||||
window.location.reload()
|
||||
} else {
|
||||
showAlert(t('settings.delete_account_failed'))
|
||||
}
|
||||
} catch (err: any) {
|
||||
showAlert(err.message || t('settings.delete_account_failed'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (logbookId) {
|
||||
loadCollaborators()
|
||||
@@ -514,30 +489,7 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
|
||||
</div>
|
||||
)}
|
||||
{/* Danger Zone / Account Deletion */}
|
||||
<div className="member-editor-card glass mt-6" style={{ borderTop: '1px solid rgba(239,68,68,0.2)', paddingTop: '24px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
|
||||
<AlertTriangle size={20} style={{ color: '#ef4444' }} />
|
||||
<h3 style={{ margin: 0, color: '#ef4444', fontSize: '16px' }}>
|
||||
{t('settings.danger_zone_title')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: '13.5px', color: '#94a3b8', lineHeight: '145%', margin: '0 0 16px 0' }}>
|
||||
{t('settings.danger_zone_desc')}
|
||||
</p>
|
||||
|
||||
<div className="form-actions" style={{ justifyContent: 'flex-start' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn danger"
|
||||
onClick={handleDeleteAccount}
|
||||
style={{ width: 'auto' }}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
{t('settings.delete_account_btn')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<AccountDangerZone className="mt-6" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { useEffect, useRef, useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Eraser } from 'lucide-react'
|
||||
import { isSignatureImage } from '../utils/signatures.js'
|
||||
|
||||
interface SignaturePadProps {
|
||||
id: string
|
||||
label: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
disabled?: boolean
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
const STROKE_COLOR = '#0f172a'
|
||||
const STROKE_WIDTH = 2.2
|
||||
|
||||
export default function SignaturePad({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
readOnly = false
|
||||
}: SignaturePadProps) {
|
||||
const { t } = useTranslation()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const isDrawing = useRef(false)
|
||||
const lastPoint = useRef<{ x: number; y: number } | null>(null)
|
||||
const skipExternalRedraw = useRef(false)
|
||||
const hasInk = useRef(false)
|
||||
const [showHint, setShowHint] = useState(() => !value)
|
||||
|
||||
const getContext = useCallback(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return null
|
||||
return canvas.getContext('2d')
|
||||
}, [])
|
||||
|
||||
const clearCanvas = useCallback(() => {
|
||||
const canvas = canvasRef.current
|
||||
const ctx = getContext()
|
||||
if (!canvas || !ctx) return
|
||||
ctx.save()
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0)
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
ctx.restore()
|
||||
hasInk.current = false
|
||||
}, [getContext])
|
||||
|
||||
const drawImageValue = useCallback((dataUrl: string) => {
|
||||
const canvas = canvasRef.current
|
||||
const ctx = getContext()
|
||||
if (!canvas || !ctx) return
|
||||
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
clearCanvas()
|
||||
const width = canvas.clientWidth
|
||||
const height = canvas.clientHeight
|
||||
ctx.drawImage(img, 0, 0, width, height)
|
||||
hasInk.current = true
|
||||
}
|
||||
img.src = dataUrl
|
||||
}, [clearCanvas, getContext])
|
||||
|
||||
const setupCanvas = useCallback(() => {
|
||||
const canvas = canvasRef.current
|
||||
const container = containerRef.current
|
||||
if (!canvas || !container) return
|
||||
|
||||
const rect = container.getBoundingClientRect()
|
||||
const width = Math.max(rect.width, 1)
|
||||
const height = Math.max(rect.height, 1)
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
|
||||
canvas.width = Math.floor(width * dpr)
|
||||
canvas.height = Math.floor(height * dpr)
|
||||
canvas.style.width = `${width}px`
|
||||
canvas.style.height = `${height}px`
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
ctx.lineCap = 'round'
|
||||
ctx.lineJoin = 'round'
|
||||
ctx.lineWidth = STROKE_WIDTH
|
||||
ctx.strokeStyle = STROKE_COLOR
|
||||
|
||||
if (value && isSignatureImage(value)) {
|
||||
drawImageValue(value)
|
||||
} else {
|
||||
clearCanvas()
|
||||
}
|
||||
}, [clearCanvas, drawImageValue, value])
|
||||
|
||||
useEffect(() => {
|
||||
setupCanvas()
|
||||
window.addEventListener('resize', setupCanvas)
|
||||
return () => window.removeEventListener('resize', setupCanvas)
|
||||
}, [setupCanvas])
|
||||
|
||||
useEffect(() => {
|
||||
if (skipExternalRedraw.current) {
|
||||
skipExternalRedraw.current = false
|
||||
return
|
||||
}
|
||||
if (value && isSignatureImage(value)) {
|
||||
drawImageValue(value)
|
||||
setShowHint(false)
|
||||
} else if (!value) {
|
||||
clearCanvas()
|
||||
setShowHint(true)
|
||||
}
|
||||
}, [value, clearCanvas, drawImageValue])
|
||||
|
||||
const getPoint = (event: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return null
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
return {
|
||||
x: event.clientX - rect.left,
|
||||
y: event.clientY - rect.top
|
||||
}
|
||||
}
|
||||
|
||||
const commitCanvas = () => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
if (!hasInk.current) {
|
||||
skipExternalRedraw.current = true
|
||||
onChange('')
|
||||
return
|
||||
}
|
||||
skipExternalRedraw.current = true
|
||||
onChange(canvas.toDataURL('image/png'))
|
||||
}
|
||||
|
||||
const handlePointerDown = (event: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (readOnly || disabled) return
|
||||
event.preventDefault()
|
||||
const point = getPoint(event)
|
||||
if (!point) return
|
||||
|
||||
isDrawing.current = true
|
||||
lastPoint.current = point
|
||||
setShowHint(false)
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
}
|
||||
|
||||
const handlePointerMove = (event: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (!isDrawing.current || readOnly || disabled) return
|
||||
event.preventDefault()
|
||||
|
||||
const point = getPoint(event)
|
||||
const ctx = getContext()
|
||||
const prev = lastPoint.current
|
||||
if (!point || !ctx || !prev) return
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(prev.x, prev.y)
|
||||
ctx.lineTo(point.x, point.y)
|
||||
ctx.stroke()
|
||||
|
||||
lastPoint.current = point
|
||||
hasInk.current = true
|
||||
}
|
||||
|
||||
const finishStroke = (event: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (!isDrawing.current) return
|
||||
isDrawing.current = false
|
||||
lastPoint.current = null
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||
}
|
||||
commitCanvas()
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
if (readOnly || disabled) return
|
||||
clearCanvas()
|
||||
skipExternalRedraw.current = true
|
||||
setShowHint(true)
|
||||
onChange('')
|
||||
}
|
||||
|
||||
const interactive = !readOnly && !disabled
|
||||
|
||||
if (readOnly && value && !isSignatureImage(value)) {
|
||||
return (
|
||||
<div className="input-group signature-pad-group">
|
||||
<label htmlFor={id}>{label}</label>
|
||||
<div className="signature-legacy-text">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="input-group signature-pad-group">
|
||||
<div className="signature-pad-header">
|
||||
<label htmlFor={id}>{label}</label>
|
||||
{interactive && (
|
||||
<button type="button" className="signature-pad-clear" onClick={handleClear}>
|
||||
<Eraser size={14} />
|
||||
{t('logs.sign_clear')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`signature-pad ${readOnly ? 'readonly' : ''} ${disabled ? 'disabled' : ''}`}
|
||||
>
|
||||
{readOnly && value && isSignatureImage(value) ? (
|
||||
<img src={value} alt={label} className="signature-pad-image" />
|
||||
) : (
|
||||
<canvas
|
||||
id={id}
|
||||
ref={canvasRef}
|
||||
className="signature-pad-canvas"
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={finishStroke}
|
||||
onPointerLeave={finishStroke}
|
||||
onPointerCancel={finishStroke}
|
||||
/>
|
||||
)}
|
||||
{interactive && showHint && (
|
||||
<span className="signature-pad-hint">{t('logs.sign_hint')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { Component, useEffect, useMemo, useRef } from 'react'
|
||||
import type { ErrorInfo, ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import L from 'leaflet'
|
||||
import type { TrackWaypoint } from '../services/trackUpload.js'
|
||||
import {
|
||||
getSegmentSpeedsKn,
|
||||
getTrackLineColor,
|
||||
hasSpeedGradientData,
|
||||
speedToTrackColor
|
||||
} from '../utils/trackMapColors.js'
|
||||
|
||||
interface TrackMapProps {
|
||||
waypoints: TrackWaypoint[]
|
||||
}
|
||||
|
||||
const LINE_WEIGHT = 5
|
||||
const LINE_OPACITY = 0.92
|
||||
|
||||
function isValidWaypoint(wp: TrackWaypoint): boolean {
|
||||
return Number.isFinite(Number(wp.lat)) && Number.isFinite(Number(wp.lng))
|
||||
}
|
||||
|
||||
function toLatLngs(waypoints: TrackWaypoint[]): [number, number][] {
|
||||
return waypoints
|
||||
.filter(isValidWaypoint)
|
||||
.map((wp) => [Number(wp.lat), Number(wp.lng)] as [number, number])
|
||||
}
|
||||
|
||||
function getTrackCenter(latLngs: [number, number][]): [number, number] {
|
||||
const avgLat = latLngs.reduce((sum, point) => sum + point[0], 0) / latLngs.length
|
||||
const avgLng = latLngs.reduce((sum, point) => sum + point[1], 0) / latLngs.length
|
||||
return [avgLat, avgLng]
|
||||
}
|
||||
|
||||
function scheduleFitMap(
|
||||
map: L.Map,
|
||||
latLngs: [number, number][],
|
||||
isCancelled: () => boolean,
|
||||
frameIds: number[]
|
||||
) {
|
||||
if (latLngs.length === 0) return
|
||||
|
||||
const fallbackCenter = latLngs.length === 1 ? latLngs[0] : getTrackCenter(latLngs)
|
||||
const fallbackZoom = latLngs.length === 1 ? 14 : 11
|
||||
|
||||
frameIds.push(
|
||||
requestAnimationFrame(() => {
|
||||
if (isCancelled()) return
|
||||
map.invalidateSize({ animate: false })
|
||||
frameIds.push(
|
||||
requestAnimationFrame(() => {
|
||||
if (isCancelled()) return
|
||||
try {
|
||||
if (latLngs.length === 1) {
|
||||
map.setView(L.latLng(latLngs[0]), 14, { animate: false })
|
||||
return
|
||||
}
|
||||
|
||||
const bounds = L.latLngBounds(latLngs.map(([lat, lng]) => L.latLng(lat, lng)))
|
||||
if (!bounds.isValid()) {
|
||||
map.setView(fallbackCenter, fallbackZoom, { animate: false })
|
||||
return
|
||||
}
|
||||
|
||||
map.fitBounds(bounds, { padding: [20, 20], maxZoom: 14, animate: false })
|
||||
} catch {
|
||||
map.setView(fallbackCenter, fallbackZoom, { animate: false })
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function TrackMapInner({ waypoints }: TrackMapProps) {
|
||||
const { t } = useTranslation()
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const validWaypoints = useMemo(() => waypoints.filter(isValidWaypoint), [waypoints])
|
||||
const segmentSpeeds = useMemo(() => getSegmentSpeedsKn(validWaypoints), [validWaypoints])
|
||||
const useGradient = hasSpeedGradientData(segmentSpeeds)
|
||||
|
||||
const speedRange = useMemo(() => {
|
||||
const valid = segmentSpeeds.filter((speed) => speed > 0)
|
||||
if (valid.length === 0) return { min: 0, max: 0 }
|
||||
return { min: Math.min(...valid), max: Math.max(...valid) }
|
||||
}, [segmentSpeeds])
|
||||
|
||||
const trackKey = useMemo(
|
||||
() =>
|
||||
validWaypoints
|
||||
.map((wp, index) => {
|
||||
const speed = index > 0 ? segmentSpeeds[index - 1] : 0
|
||||
return `${wp.lat},${wp.lng},${speed.toFixed(1)}`
|
||||
})
|
||||
.join('|'),
|
||||
[validWaypoints, segmentSpeeds]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container || validWaypoints.length === 0) return
|
||||
|
||||
let cancelled = false
|
||||
const pendingFrames: number[] = []
|
||||
const isCancelled = () => cancelled
|
||||
|
||||
const map = L.map(container, {
|
||||
zoomControl: true,
|
||||
attributionControl: true
|
||||
})
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: '© <a href="https://openstreetmap.org">OpenStreetMap</a> contributors'
|
||||
}).addTo(map)
|
||||
|
||||
L.tileLayer('https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png', {
|
||||
maxZoom: 18,
|
||||
attribution: 'Map data © <a href="http://openseamap.org">OpenSeaMap</a> contributors'
|
||||
}).addTo(map)
|
||||
|
||||
const trackGroup = L.layerGroup().addTo(map)
|
||||
const latLngs = toLatLngs(validWaypoints)
|
||||
|
||||
if (useGradient && latLngs.length >= 2) {
|
||||
for (let i = 1; i < latLngs.length; i++) {
|
||||
const speedKn = segmentSpeeds[i - 1] ?? 0
|
||||
const color = speedToTrackColor(speedKn, speedRange.min, speedRange.max)
|
||||
L.polyline([latLngs[i - 1], latLngs[i]], {
|
||||
color,
|
||||
weight: LINE_WEIGHT,
|
||||
opacity: LINE_OPACITY,
|
||||
lineCap: 'round',
|
||||
lineJoin: 'round'
|
||||
}).addTo(trackGroup)
|
||||
}
|
||||
} else if (latLngs.length >= 2) {
|
||||
L.polyline(latLngs, {
|
||||
color: getTrackLineColor(segmentSpeeds),
|
||||
weight: LINE_WEIGHT,
|
||||
opacity: LINE_OPACITY,
|
||||
lineCap: 'round',
|
||||
lineJoin: 'round'
|
||||
}).addTo(trackGroup)
|
||||
}
|
||||
|
||||
if (latLngs.length > 0) {
|
||||
L.circleMarker(latLngs[0], {
|
||||
radius: 8,
|
||||
fillColor: '#10b981',
|
||||
fillOpacity: 0.9,
|
||||
color: '#ffffff',
|
||||
weight: 2
|
||||
})
|
||||
.addTo(trackGroup)
|
||||
.bindPopup(t('logs.track_map_start'))
|
||||
}
|
||||
|
||||
if (latLngs.length > 1) {
|
||||
L.circleMarker(latLngs[latLngs.length - 1], {
|
||||
radius: 8,
|
||||
fillColor: '#ef4444',
|
||||
fillOpacity: 0.9,
|
||||
color: '#ffffff',
|
||||
weight: 2
|
||||
})
|
||||
.addTo(trackGroup)
|
||||
.bindPopup(t('logs.track_map_end'))
|
||||
}
|
||||
|
||||
scheduleFitMap(map, latLngs, isCancelled, pendingFrames)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
pendingFrames.forEach((id) => cancelAnimationFrame(id))
|
||||
map.remove()
|
||||
}
|
||||
}, [trackKey, validWaypoints, segmentSpeeds, speedRange.min, speedRange.max, useGradient, t])
|
||||
|
||||
if (validWaypoints.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="track-map-wrapper">
|
||||
<div
|
||||
className="track-map-container"
|
||||
ref={containerRef}
|
||||
aria-label={t('logs.track_map_title')}
|
||||
/>
|
||||
{useGradient && (
|
||||
<div className="track-map-legend" aria-hidden="true">
|
||||
<span>{t('logs.track_map_speed_slow')}</span>
|
||||
<div className="track-map-legend-bar" />
|
||||
<span>{t('logs.track_map_speed_fast')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
class TrackMapErrorBoundary extends Component<
|
||||
{ children: ReactNode; fallback: ReactNode },
|
||||
{ hasError: boolean }
|
||||
> {
|
||||
state = { hasError: false }
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error('TrackMap render failed:', error, info)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) return this.props.fallback
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
export default function TrackMap(props: TrackMapProps) {
|
||||
const { t } = useTranslation()
|
||||
const remountKey = props.waypoints.filter(isValidWaypoint).length
|
||||
|
||||
return (
|
||||
<TrackMapErrorBoundary
|
||||
key={remountKey}
|
||||
fallback={<div className="track-error-msg">{t('logs.track_map_error')}</div>}
|
||||
>
|
||||
<TrackMapInner {...props} />
|
||||
</TrackMapErrorBoundary>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"translation": {
|
||||
"app": {
|
||||
"name": "Kapteins Daagbox",
|
||||
"name": "Kapteins Daagbok",
|
||||
"tagline": "Privates Yacht-Logbuch"
|
||||
},
|
||||
"nav": {
|
||||
@@ -13,7 +13,7 @@
|
||||
"settings": "Einstellungen"
|
||||
},
|
||||
"auth": {
|
||||
"welcome": "Willkommen bei Kapteins Daagbox",
|
||||
"welcome": "Willkommen bei Kapteins Daagbok",
|
||||
"tagline": "Sicheres, E2E-verschlüsseltes maritimes Logbuch.",
|
||||
"register": "Mit Passkey registrieren",
|
||||
"login": "Mit Passkey anmelden",
|
||||
@@ -55,7 +55,7 @@
|
||||
},
|
||||
"pwa": {
|
||||
"title": "App installieren",
|
||||
"generic_benefit": "Installieren Sie Kapteins Daagbox auf Ihrem Gerät für schnelleren Zugriff, Offline-Nutzung und dauerhafte Datenspeicherung.",
|
||||
"generic_benefit": "Installieren Sie Kapteins Daagbok auf Ihrem Gerät für schnelleren Zugriff, Offline-Nutzung und dauerhafte Datenspeicherung.",
|
||||
"ios_instructions": "Auf dem iPad/iPhone: Fügen Sie die App zum Home-Bildschirm hinzu, damit Ihre Logbuchdaten geschützt bleiben und die App wie eine native App startet.",
|
||||
"ios_step_share": "Teilen-Symbol in der Safari-Leiste antippen",
|
||||
"ios_step_add": "„Zum Home-Bildschirm“ wählen",
|
||||
@@ -113,8 +113,10 @@
|
||||
"evening": "Stand abends",
|
||||
"consumption": "Tagesverbrauch",
|
||||
"signatures": "Unterschriften / Freigabe",
|
||||
"sign_skipper": "Skipper (Blockschrift)",
|
||||
"sign_crew": "Crew-Mitglied (Blockschrift)",
|
||||
"sign_skipper": "Skipper-Unterschrift",
|
||||
"sign_crew": "Crew-Unterschrift",
|
||||
"sign_hint": "Mit Finger, Stift oder Maus unterschreiben",
|
||||
"sign_clear": "Löschen",
|
||||
"no_entries": "Keine Logbucheinträge für diese Yacht gefunden. Erstellen Sie Ihren ersten Reisetag!",
|
||||
"back_to_list": "Zurück zur Journal-Liste",
|
||||
"save": "Logbuchseite speichern",
|
||||
@@ -123,6 +125,10 @@
|
||||
"loading": "Journal wird geladen...",
|
||||
"delete_entry": "Tag löschen",
|
||||
"delete_confirm": "Sind Sie sicher, dass Sie diesen Reisetag unwiderruflich löschen möchten?",
|
||||
"carry_over_tanks_title": "Tankstände übernehmen?",
|
||||
"carry_over_tanks_confirm": "Morgenstände vom letzten Reisetag als Startwerte übernehmen?\n\nFrischwasser: {{fw}} L\nKraftstoff: {{fuel}} L",
|
||||
"carry_over_tanks_yes": "Übernehmen",
|
||||
"carry_over_tanks_no": "Mit 0 starten",
|
||||
"event_title": "Chronologisches Ereignisprotokoll",
|
||||
"no_events": "Noch keine Ereignisse für diesen Reisetag eingetragen.",
|
||||
"event_time": "Uhrzeit",
|
||||
@@ -157,14 +163,22 @@
|
||||
"photo_delete_confirm": "Sind Sie sicher, dass Sie dieses Foto unwiderruflich löschen möchten?",
|
||||
"confirm_yes": "Ja",
|
||||
"confirm_no": "Nein",
|
||||
"gps_tracking_title": "GPS-Route (OpenSeaMap)",
|
||||
"track_upload_title": "GPS-Track (Datei)",
|
||||
"track_upload_points": "Punkte",
|
||||
"gps_tracking_btn_gpx": "Track-Datei herunterladen",
|
||||
"gps_tracking_stat_distance": "Track-Distanz",
|
||||
"gps_tracking_stat_waypoints": "Wegpunkte",
|
||||
"gps_track_upload_help": "Ziehen Sie eine GPX-, KML- oder GeoJSON-Datei hierher oder klicken Sie zum Auswählen",
|
||||
"gps_track_upload_btn": "GPS-Track hochladen",
|
||||
"gps_track_delete": "Track-Datei löschen",
|
||||
"gps_track_delete_confirm": "Sind Sie sicher, dass Sie diese Track-Datei dauerhaft löschen möchten?",
|
||||
"track_distance": "GPS-Strecke (sm)",
|
||||
"track_speed_max": "Max. Geschwindigkeit (kn)",
|
||||
"track_speed_avg": "Ø Geschwindigkeit (kn)",
|
||||
"track_map_title": "GPS-Track auf OpenSeaMap",
|
||||
"track_map_start": "Start",
|
||||
"track_map_end": "Ziel",
|
||||
"track_map_speed_slow": "langsam",
|
||||
"track_map_speed_fast": "schnell",
|
||||
"track_map_error": "Karte konnte nicht geladen werden.",
|
||||
"exporting": "Exportiere...",
|
||||
"share_unsupported": "Teilen wird auf diesem Gerät nicht unterstützt. Datei wurde stattdessen heruntergeladen.",
|
||||
"invite_crew": "Crew einladen",
|
||||
@@ -253,7 +267,8 @@
|
||||
"delete_account_confirm_desc": "Sind Sie absolut sicher, dass Sie Ihr Konto und alle zugehörigen Logbücher und E2E-verschlüsselten Daten unwiderruflich löschen möchten?",
|
||||
"delete_account_confirm_yes": "Ja, Konto und alle Daten löschen",
|
||||
"delete_account_confirm_no": "Abbrechen",
|
||||
"delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuchen Sie es erneut."
|
||||
"delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuchen Sie es erneut.",
|
||||
"deleting_account": "Konto wird gelöscht…"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"translation": {
|
||||
"app": {
|
||||
"name": "Kapteins Daagbox",
|
||||
"name": "Kapteins Daagbok",
|
||||
"tagline": "Private Yacht Logbook"
|
||||
},
|
||||
"nav": {
|
||||
@@ -13,7 +13,7 @@
|
||||
"settings": "Settings"
|
||||
},
|
||||
"auth": {
|
||||
"welcome": "Welcome to Kapteins Daagbox",
|
||||
"welcome": "Welcome to Kapteins Daagbok",
|
||||
"tagline": "Secure, E2E encrypted maritime logbook.",
|
||||
"register": "Register with Passkey",
|
||||
"login": "Login with Passkey",
|
||||
@@ -55,7 +55,7 @@
|
||||
},
|
||||
"pwa": {
|
||||
"title": "Install app",
|
||||
"generic_benefit": "Install Kapteins Daagbox on your device for faster access, offline use, and persistent data storage.",
|
||||
"generic_benefit": "Install Kapteins Daagbok on your device for faster access, offline use, and persistent data storage.",
|
||||
"ios_instructions": "On iPad/iPhone: Add the app to your Home Screen so your logbook data stays protected and the app launches like a native app.",
|
||||
"ios_step_share": "Tap the Share button in the Safari toolbar",
|
||||
"ios_step_add": "Choose “Add to Home Screen”",
|
||||
@@ -113,8 +113,10 @@
|
||||
"evening": "Evening Level",
|
||||
"consumption": "Consumption",
|
||||
"signatures": "Signatures / Sign-Off",
|
||||
"sign_skipper": "Skipper Signature",
|
||||
"sign_crew": "Crew Signature",
|
||||
"sign_skipper": "Skipper signature",
|
||||
"sign_crew": "Crew signature",
|
||||
"sign_hint": "Sign with finger, stylus, or mouse",
|
||||
"sign_clear": "Clear",
|
||||
"no_entries": "No logbook entries found for this yacht. Create your first travel day to begin!",
|
||||
"back_to_list": "Back to Journal List",
|
||||
"save": "Save Logbook Page",
|
||||
@@ -123,6 +125,10 @@
|
||||
"loading": "Loading journal...",
|
||||
"delete_entry": "Delete Day",
|
||||
"delete_confirm": "Are you sure you want to permanently delete this travel day?",
|
||||
"carry_over_tanks_title": "Carry over tank levels?",
|
||||
"carry_over_tanks_confirm": "Use the previous travel day's closing levels as morning levels?\n\nFreshwater: {{fw}} L\nFuel: {{fuel}} L",
|
||||
"carry_over_tanks_yes": "Carry over",
|
||||
"carry_over_tanks_no": "Start at 0",
|
||||
"event_title": "Chronological Event Logbook",
|
||||
"no_events": "No events logged for this travel day yet.",
|
||||
"event_time": "Time",
|
||||
@@ -157,14 +163,22 @@
|
||||
"photo_delete_confirm": "Are you sure you want to permanently delete this photo?",
|
||||
"confirm_yes": "Yes",
|
||||
"confirm_no": "No",
|
||||
"gps_tracking_title": "GPS Route (OpenSeaMap)",
|
||||
"gps_tracking_btn_gpx": "Download Track File",
|
||||
"gps_tracking_stat_distance": "Track Distance",
|
||||
"gps_tracking_stat_waypoints": "Points",
|
||||
"track_upload_title": "GPS track file",
|
||||
"track_upload_points": "points",
|
||||
"gps_tracking_btn_gpx": "Download track file",
|
||||
"gps_track_upload_help": "Drag & drop a GPX, KML, or GeoJSON file here, or click to select",
|
||||
"gps_track_upload_btn": "Upload GPS Track File",
|
||||
"gps_track_delete": "Delete Track File",
|
||||
"gps_track_delete_confirm": "Are you sure you want to permanently delete this track file?",
|
||||
"track_distance": "GPS distance (nm)",
|
||||
"track_speed_max": "Max speed (kn)",
|
||||
"track_speed_avg": "Avg speed (kn)",
|
||||
"track_map_title": "GPS track on OpenSeaMap",
|
||||
"track_map_start": "Start",
|
||||
"track_map_end": "End",
|
||||
"track_map_speed_slow": "slow",
|
||||
"track_map_speed_fast": "fast",
|
||||
"track_map_error": "Could not load map.",
|
||||
"exporting": "Exporting...",
|
||||
"share_unsupported": "Web sharing is not supported on this device. File downloaded instead.",
|
||||
"invite_crew": "Invite Crew",
|
||||
@@ -253,7 +267,8 @@
|
||||
"delete_account_confirm_desc": "Are you absolutely sure you want to permanently delete your account and all associated logbooks and E2E-encrypted data?",
|
||||
"delete_account_confirm_yes": "Yes, Delete Account and All Data",
|
||||
"delete_account_confirm_no": "Cancel",
|
||||
"delete_account_failed": "Failed to delete account. Please try again."
|
||||
"delete_account_failed": "Failed to delete account. Please try again.",
|
||||
"deleting_account": "Deleting account…"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import './i18n'
|
||||
|
||||
@@ -2,6 +2,7 @@ import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { getLogbookKey } from './logbookKeys.js'
|
||||
import { decryptJson } from './crypto.js'
|
||||
import { formatSignatureForExport } from '../utils/signatures.js'
|
||||
|
||||
function escapeCsvValue(val: string | number | undefined | null): string {
|
||||
if (val === null || val === undefined) return '';
|
||||
@@ -77,6 +78,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
const headers = [
|
||||
'Date', 'Day of Travel', 'Departure Port', 'Destination Port',
|
||||
'Skipper Signature', 'Crew Signature',
|
||||
'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)',
|
||||
'Event Time', 'MgK Course', 'RwK Course',
|
||||
'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State',
|
||||
'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)',
|
||||
@@ -93,8 +95,11 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
const travelDay = entry.dayOfTravel || '';
|
||||
const dep = entry.departure || '';
|
||||
const dest = entry.destination || '';
|
||||
const signS = entry.signSkipper || '';
|
||||
const signC = entry.signCrew || '';
|
||||
const signS = formatSignatureForExport(entry.signSkipper);
|
||||
const signC = formatSignatureForExport(entry.signCrew);
|
||||
const trackDist = entry.trackDistanceNm ?? '';
|
||||
const trackMax = entry.trackSpeedMaxKn ?? '';
|
||||
const trackAvg = entry.trackSpeedAvgKn ?? '';
|
||||
const fwM = entry.freshwater?.morning ?? '';
|
||||
const fwR = entry.freshwater?.refilled ?? '';
|
||||
const fwE = entry.freshwater?.evening ?? '';
|
||||
@@ -110,6 +115,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
rows.push([
|
||||
dateVal, travelDay, dep, dest,
|
||||
signS, signC,
|
||||
trackDist, trackMax, trackAvg,
|
||||
'', '', '',
|
||||
'', '', '', '',
|
||||
'', '', '', '', '',
|
||||
@@ -125,6 +131,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
rows.push([
|
||||
dateVal, travelDay, dep, dest,
|
||||
signS, signC,
|
||||
trackDist, trackMax, trackAvg,
|
||||
ev.time || '', ev.mgk || '', ev.rwk || '',
|
||||
ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '',
|
||||
ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '',
|
||||
@@ -166,7 +173,7 @@ export async function shareCsv(logbookId: string, title: string, preloadedData?:
|
||||
try {
|
||||
await navigator.share({
|
||||
files: [file],
|
||||
title: `Kapteins Daagbox - ${title}`,
|
||||
title: `Kapteins Daagbok - ${title}`,
|
||||
text: `Logbook export for yacht ${title}`
|
||||
});
|
||||
} catch (e: any) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { getLogbookKey } from './logbookKeys.js'
|
||||
import { decryptJson } from './crypto.js'
|
||||
import { isSignatureImage } from '../utils/signatures.js'
|
||||
|
||||
export async function generateLogbookPagePdf(logbookId: string, entryId: string, preloadedData?: { yacht: any; entry: any }): Promise<jsPDF> {
|
||||
let yachtName = '', homePort = '', registration = '', callsign = '', atis = '', mmsi = '';
|
||||
@@ -77,10 +78,19 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
|
||||
doc.text(`ATIS: ${atis || '—'}`, 210, 21);
|
||||
doc.text(`MMSI: ${mmsi || '—'}`, 250, 21);
|
||||
|
||||
doc.text(`Datum: ${entry.date || '—'}`, 10, 26);
|
||||
doc.text(`Reisetag: ${entry.dayOfTravel || '—'}`, 60, 26);
|
||||
doc.text(`Reise von (Departure): ${entry.departure || '—'}`, 110, 26);
|
||||
doc.text(`nach (Destination): ${entry.destination || '—'}`, 200, 26);
|
||||
doc.text(`Datum: ${entry.date || '—'}`, 10, 23);
|
||||
doc.text(`Reisetag: ${entry.dayOfTravel || '—'}`, 60, 23);
|
||||
doc.text(`Reise von (Departure): ${entry.departure || '—'}`, 110, 23);
|
||||
doc.text(`nach (Destination): ${entry.destination || '—'}`, 200, 23);
|
||||
|
||||
if (entry.trackDistanceNm) {
|
||||
doc.setFont('Helvetica', 'normal');
|
||||
doc.text(
|
||||
`GPS-Track: ${entry.trackDistanceNm} sm · max. ${entry.trackSpeedMaxKn ?? '—'} kn · Ø ${entry.trackSpeedAvgKn ?? '—'} kn`,
|
||||
10,
|
||||
27
|
||||
);
|
||||
}
|
||||
|
||||
// Divider line
|
||||
doc.setLineWidth(0.3);
|
||||
@@ -219,14 +229,22 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
|
||||
doc.line(sigX, sigY + rowHeight * 1.5, sigX + 157, sigY + rowHeight * 1.5);
|
||||
doc.line(sigX + 78.5, sigY, sigX + 78.5, sigY + rowHeight * 3);
|
||||
|
||||
doc.text('Skipper Unterschrift (in Blockschrift):', sigX + 2, sigY + 4.2);
|
||||
doc.setFont('Helvetica', 'normal');
|
||||
doc.text(String(entry.signSkipper || '—').toUpperCase(), sigX + 2, sigY + 11.2);
|
||||
doc.text('Skipper Unterschrift:', sigX + 2, sigY + 4.2);
|
||||
if (isSignatureImage(entry.signSkipper)) {
|
||||
doc.addImage(entry.signSkipper, 'PNG', sigX + 2, sigY + 6, 72, 14)
|
||||
} else {
|
||||
doc.setFont('Helvetica', 'normal');
|
||||
doc.text(String(entry.signSkipper || '—').toUpperCase(), sigX + 2, sigY + 11.2);
|
||||
}
|
||||
|
||||
doc.setFont('Helvetica', 'bold');
|
||||
doc.text('Crew Unterschrift (in Blockschrift):', sigX + 80.5, sigY + 4.2);
|
||||
doc.setFont('Helvetica', 'normal');
|
||||
doc.text(String(entry.signCrew || '—').toUpperCase(), sigX + 80.5, sigY + 11.2);
|
||||
doc.text('Crew Unterschrift:', sigX + 80.5, sigY + 4.2);
|
||||
if (isSignatureImage(entry.signCrew)) {
|
||||
doc.addImage(entry.signCrew, 'PNG', sigX + 80.5, sigY + 6, 72, 14)
|
||||
} else {
|
||||
doc.setFont('Helvetica', 'normal');
|
||||
doc.text(String(entry.signCrew || '—').toUpperCase(), sigX + 80.5, sigY + 11.2);
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getLogbookKey } from './logbookKeys.js'
|
||||
import { encryptJson, decryptJson } from './crypto.js'
|
||||
import { syncLogbook } from './sync.js'
|
||||
|
||||
export interface GpsWaypoint {
|
||||
export interface TrackWaypoint {
|
||||
timestamp: number
|
||||
lat: number
|
||||
lng: number
|
||||
@@ -12,15 +12,14 @@ export interface GpsWaypoint {
|
||||
heading?: number
|
||||
}
|
||||
|
||||
export interface SavedGpsTrack {
|
||||
waypoints: GpsWaypoint[]
|
||||
gpxContent: string // Holds the raw text file content (GPX, KML or GeoJSON)
|
||||
export interface SavedTrack {
|
||||
waypoints: TrackWaypoint[]
|
||||
gpxContent: string
|
||||
filename: string
|
||||
fileType: string // 'gpx' | 'kml' | 'geojson'
|
||||
fileType: string
|
||||
}
|
||||
|
||||
// Get the decrypted track data for a journal entry (with legacy array format compatibility)
|
||||
export async function getDecryptedGpsTrack(entryId: string): Promise<SavedGpsTrack | null> {
|
||||
export async function getDecryptedTrack(entryId: string): Promise<SavedTrack | null> {
|
||||
const record = await db.gpsTracks.get(entryId)
|
||||
if (!record) return null
|
||||
|
||||
@@ -33,45 +32,41 @@ export async function getDecryptedGpsTrack(entryId: string): Promise<SavedGpsTra
|
||||
try {
|
||||
const decrypted = await decryptJson(record.encryptedData, record.iv, record.tag, masterKey)
|
||||
if (Array.isArray(decrypted)) {
|
||||
// Legacy format (just coordinate array)
|
||||
return {
|
||||
waypoints: decrypted,
|
||||
gpxContent: generateLegacyGpxString(decrypted, 'legacy'),
|
||||
gpxContent: buildLegacyGpx(decrypted, 'legacy'),
|
||||
filename: 'track_legacy.gpx',
|
||||
fileType: 'gpx'
|
||||
}
|
||||
}
|
||||
return decrypted
|
||||
return decrypted as SavedTrack
|
||||
} catch (err) {
|
||||
console.error('Failed to decrypt GPS track:', err)
|
||||
console.error('Failed to decrypt track file:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Encrypt and save uploaded GPS track to local Dexie and remote sync
|
||||
export async function saveUploadedGpsTrack(
|
||||
export async function saveUploadedTrack(
|
||||
logbookId: string,
|
||||
entryId: string,
|
||||
gpxContent: string,
|
||||
waypoints: GpsWaypoint[],
|
||||
fileContent: string,
|
||||
waypoints: TrackWaypoint[],
|
||||
filename: string,
|
||||
fileType: string
|
||||
): Promise<void> {
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
|
||||
const trackData: SavedGpsTrack = {
|
||||
const trackData: SavedTrack = {
|
||||
waypoints,
|
||||
gpxContent,
|
||||
gpxContent: fileContent,
|
||||
filename,
|
||||
fileType
|
||||
}
|
||||
|
||||
// Encrypt JSON
|
||||
const encrypted = await encryptJson(trackData, masterKey)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// Save to Dexie
|
||||
await db.gpsTracks.put({
|
||||
entryId,
|
||||
logbookId,
|
||||
@@ -81,7 +76,6 @@ export async function saveUploadedGpsTrack(
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
// Add to Sync queue (payloadId is entryId)
|
||||
await db.syncQueue.put({
|
||||
action: 'create',
|
||||
type: 'gpsTrack',
|
||||
@@ -91,18 +85,14 @@ export async function saveUploadedGpsTrack(
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
// Trigger sync
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
}
|
||||
|
||||
// Delete GPS track from local DB and sync queue
|
||||
export async function deleteGpsTrack(logbookId: string, entryId: string): Promise<void> {
|
||||
export async function deleteTrack(logbookId: string, entryId: string): Promise<void> {
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// Delete from Dexie
|
||||
await db.gpsTracks.delete(entryId)
|
||||
|
||||
// Add to Sync queue
|
||||
await db.syncQueue.put({
|
||||
action: 'delete',
|
||||
type: 'gpsTrack',
|
||||
@@ -112,12 +102,10 @@ export async function deleteGpsTrack(logbookId: string, entryId: string): Promis
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
// Trigger sync
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
}
|
||||
|
||||
// Download the track file exactly as uploaded
|
||||
export function downloadTrackFile(track: SavedGpsTrack): void {
|
||||
export function downloadTrackFile(track: SavedTrack): void {
|
||||
const blob = new Blob([track.gpxContent], { type: 'text/plain;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
|
||||
@@ -130,24 +118,22 @@ export function downloadTrackFile(track: SavedGpsTrack): void {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
// Main parser entry point
|
||||
export function parseTrackFile(text: string, filename: string): { waypoints: GpsWaypoint[]; type: string } {
|
||||
export function parseTrackFile(text: string, filename: string): { waypoints: TrackWaypoint[]; type: string } {
|
||||
const lowerName = filename.toLowerCase()
|
||||
if (lowerName.endsWith('.kml') || text.includes('<kml')) {
|
||||
return { waypoints: parseKmlFile(text), type: 'kml' }
|
||||
} else if (lowerName.endsWith('.json') || lowerName.endsWith('.geojson') || text.trim().startsWith('{')) {
|
||||
return { waypoints: parseGeoJsonFile(text), type: 'geojson' }
|
||||
} else {
|
||||
return { waypoints: parseGpxFile(text), type: 'gpx' }
|
||||
}
|
||||
if (lowerName.endsWith('.json') || lowerName.endsWith('.geojson') || text.trim().startsWith('{')) {
|
||||
return { waypoints: parseGeoJsonFile(text), type: 'geojson' }
|
||||
}
|
||||
return { waypoints: parseGpxFile(text), type: 'gpx' }
|
||||
}
|
||||
|
||||
// 1. GPX Parser
|
||||
export function parseGpxFile(gpxText: string): GpsWaypoint[] {
|
||||
function parseGpxFile(gpxText: string): TrackWaypoint[] {
|
||||
const parser = new DOMParser()
|
||||
const xmlDoc = parser.parseFromString(gpxText, 'text/xml')
|
||||
const trackPoints = xmlDoc.getElementsByTagName('trkpt')
|
||||
const waypoints: GpsWaypoint[] = []
|
||||
const waypoints: TrackWaypoint[] = []
|
||||
|
||||
for (let i = 0; i < trackPoints.length; i++) {
|
||||
const el = trackPoints[i]
|
||||
@@ -156,13 +142,13 @@ export function parseGpxFile(gpxText: string): GpsWaypoint[] {
|
||||
if (isNaN(lat) || isNaN(lon)) continue
|
||||
|
||||
const timeEl = el.getElementsByTagName('time')[0]
|
||||
const timestamp = timeEl && timeEl.textContent ? new Date(timeEl.textContent).getTime() : Date.now()
|
||||
const timestamp = timeEl?.textContent ? new Date(timeEl.textContent).getTime() : Date.now()
|
||||
|
||||
const speedEl = el.getElementsByTagName('speed')[0]
|
||||
const speedKnots = speedEl && speedEl.textContent ? parseFloat(speedEl.textContent) * 1.94384 : undefined
|
||||
const speedKnots = speedEl?.textContent ? parseFloat(speedEl.textContent) * 1.94384 : undefined
|
||||
|
||||
const courseEl = el.getElementsByTagName('course')[0] || el.getElementsByTagName('heading')[0]
|
||||
const heading = courseEl && courseEl.textContent ? parseFloat(courseEl.textContent) : undefined
|
||||
const heading = courseEl?.textContent ? parseFloat(courseEl.textContent) : undefined
|
||||
|
||||
waypoints.push({
|
||||
timestamp,
|
||||
@@ -175,18 +161,15 @@ export function parseGpxFile(gpxText: string): GpsWaypoint[] {
|
||||
return waypoints
|
||||
}
|
||||
|
||||
// 2. KML Parser
|
||||
export function parseKmlFile(kmlText: string): GpsWaypoint[] {
|
||||
function parseKmlFile(kmlText: string): TrackWaypoint[] {
|
||||
const parser = new DOMParser()
|
||||
const xmlDoc = parser.parseFromString(kmlText, 'text/xml')
|
||||
const waypoints: GpsWaypoint[] = []
|
||||
const waypoints: TrackWaypoint[] = []
|
||||
|
||||
// Check for standard KML <coordinates> tags
|
||||
const coordsTags = xmlDoc.getElementsByTagName('coordinates')
|
||||
for (let i = 0; i < coordsTags.length; i++) {
|
||||
const text = coordsTags[i].textContent || ''
|
||||
const coordStrings = text.trim().split(/\s+/)
|
||||
for (const str of coordStrings) {
|
||||
for (const str of text.trim().split(/\s+/)) {
|
||||
const parts = str.split(',')
|
||||
if (parts.length >= 2) {
|
||||
const lon = parseFloat(parts[0])
|
||||
@@ -202,22 +185,18 @@ export function parseKmlFile(kmlText: string): GpsWaypoint[] {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for gx:coord extensions (commonly used in Google Earth tracks)
|
||||
const gxCoords = xmlDoc.getElementsByTagName('gx:coord')
|
||||
if (gxCoords.length > 0) {
|
||||
for (let i = 0; i < gxCoords.length; i++) {
|
||||
const text = gxCoords[i].textContent || ''
|
||||
const parts = text.trim().split(/\s+/)
|
||||
if (parts.length >= 2) {
|
||||
const lon = parseFloat(parts[0])
|
||||
const lat = parseFloat(parts[1])
|
||||
if (!isNaN(lat) && !isNaN(lon)) {
|
||||
waypoints.push({
|
||||
timestamp: Date.now(),
|
||||
lat: Number(lat.toFixed(6)),
|
||||
lng: Number(lon.toFixed(6))
|
||||
})
|
||||
}
|
||||
for (let i = 0; i < gxCoords.length; i++) {
|
||||
const parts = (gxCoords[i].textContent || '').trim().split(/\s+/)
|
||||
if (parts.length >= 2) {
|
||||
const lon = parseFloat(parts[0])
|
||||
const lat = parseFloat(parts[1])
|
||||
if (!isNaN(lat) && !isNaN(lon)) {
|
||||
waypoints.push({
|
||||
timestamp: Date.now(),
|
||||
lat: Number(lat.toFixed(6)),
|
||||
lng: Number(lon.toFixed(6))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -225,9 +204,8 @@ export function parseKmlFile(kmlText: string): GpsWaypoint[] {
|
||||
return waypoints
|
||||
}
|
||||
|
||||
// 3. GeoJSON Parser
|
||||
export function parseGeoJsonFile(geoJsonText: string): GpsWaypoint[] {
|
||||
const waypoints: GpsWaypoint[] = []
|
||||
function parseGeoJsonFile(geoJsonText: string): TrackWaypoint[] {
|
||||
const waypoints: TrackWaypoint[] = []
|
||||
try {
|
||||
const data = JSON.parse(geoJsonText)
|
||||
|
||||
@@ -235,40 +213,22 @@ export function parseGeoJsonFile(geoJsonText: string): GpsWaypoint[] {
|
||||
if (!geom) return
|
||||
if (geom.type === 'LineString' && Array.isArray(geom.coordinates)) {
|
||||
for (const coord of geom.coordinates) {
|
||||
const lon = coord[0]
|
||||
const lat = coord[1]
|
||||
if (typeof lat === 'number' && typeof lon === 'number') {
|
||||
waypoints.push({
|
||||
timestamp: Date.now(),
|
||||
lat: Number(lat.toFixed(6)),
|
||||
lng: Number(lon.toFixed(6))
|
||||
})
|
||||
}
|
||||
pushCoord(waypoints, coord)
|
||||
}
|
||||
} else if (geom.type === 'MultiLineString' && Array.isArray(geom.coordinates)) {
|
||||
for (const line of geom.coordinates) {
|
||||
if (Array.isArray(line)) {
|
||||
for (const coord of line) {
|
||||
const lon = coord[0]
|
||||
const lat = coord[1]
|
||||
if (typeof lat === 'number' && typeof lon === 'number') {
|
||||
waypoints.push({
|
||||
timestamp: Date.now(),
|
||||
lat: Number(lat.toFixed(6)),
|
||||
lng: Number(lon.toFixed(6))
|
||||
})
|
||||
}
|
||||
pushCoord(waypoints, coord)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (data.type === 'FeatureCollection' && Array.isArray(data.features)) {
|
||||
for (const feature of data.features) {
|
||||
if (feature && feature.geometry) {
|
||||
processGeometry(feature.geometry)
|
||||
}
|
||||
if (feature?.geometry) processGeometry(feature.geometry)
|
||||
}
|
||||
} else if (data.type === 'Feature' && data.geometry) {
|
||||
processGeometry(data.geometry)
|
||||
@@ -282,8 +242,19 @@ export function parseGeoJsonFile(geoJsonText: string): GpsWaypoint[] {
|
||||
return waypoints
|
||||
}
|
||||
|
||||
// Generate legacy fallback GPX string
|
||||
function generateLegacyGpxString(waypoints: GpsWaypoint[], dateStr: string): string {
|
||||
function pushCoord(waypoints: TrackWaypoint[], coord: number[]) {
|
||||
const lon = coord[0]
|
||||
const lat = coord[1]
|
||||
if (typeof lat === 'number' && typeof lon === 'number') {
|
||||
waypoints.push({
|
||||
timestamp: Date.now(),
|
||||
lat: Number(lat.toFixed(6)),
|
||||
lng: Number(lon.toFixed(6))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function buildLegacyGpx(waypoints: TrackWaypoint[], dateStr: string): string {
|
||||
const trkpts = waypoints
|
||||
.map((wp) => {
|
||||
const timeISO = new Date(wp.timestamp).toISOString()
|
||||
@@ -294,7 +265,7 @@ function generateLegacyGpxString(waypoints: GpsWaypoint[], dateStr: string): str
|
||||
.join('\n')
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="Kapteins Daagbox" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<gpx version="1.1" creator="Kapteins Daagbok" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<metadata>
|
||||
<time>${new Date().toISOString()}</time>
|
||||
</metadata>
|
||||
@@ -0,0 +1,64 @@
|
||||
export interface TankLevels {
|
||||
morning: number
|
||||
refilled: number
|
||||
evening: number
|
||||
consumption: number
|
||||
}
|
||||
|
||||
export interface TravelDaySortable {
|
||||
date?: string
|
||||
dayOfTravel?: string | number
|
||||
}
|
||||
|
||||
/** Chronological order: date ascending, then day of travel ascending. */
|
||||
export function compareTravelDaysChronological(a: TravelDaySortable, b: TravelDaySortable): number {
|
||||
const dateCompare = new Date(a.date || 0).getTime() - new Date(b.date || 0).getTime()
|
||||
if (dateCompare !== 0) return dateCompare
|
||||
return Number(a.dayOfTravel || 0) - Number(b.dayOfTravel || 0)
|
||||
}
|
||||
|
||||
export function getNextTravelDayNumber(entries: TravelDaySortable[]): string {
|
||||
const maxDay = entries.reduce((max, entry) => Math.max(max, Number(entry.dayOfTravel) || 0), 0)
|
||||
return String(maxDay + 1)
|
||||
}
|
||||
|
||||
/** Closing level at end of travel day: evening stand, else calculated balance, else morning. */
|
||||
export function getClosingTankLevel(tank?: Partial<TankLevels> | null): number {
|
||||
if (!tank) return 0
|
||||
|
||||
const evening = Number(tank.evening) || 0
|
||||
if (evening > 0) return evening
|
||||
|
||||
const morning = Number(tank.morning) || 0
|
||||
const refilled = Number(tank.refilled) || 0
|
||||
const consumption = Number(tank.consumption) || 0
|
||||
const fromBalance = morning + refilled - consumption
|
||||
if (fromBalance > 0) return fromBalance
|
||||
|
||||
return morning
|
||||
}
|
||||
|
||||
export interface LogEntryTankSource {
|
||||
freshwater?: Partial<TankLevels>
|
||||
fuel?: Partial<TankLevels>
|
||||
}
|
||||
|
||||
export function emptyTankLevels(morning = 0): TankLevels {
|
||||
return { morning, refilled: 0, evening: 0, consumption: 0 }
|
||||
}
|
||||
|
||||
export function formatTankLiters(liters: number): string {
|
||||
if (!Number.isFinite(liters) || liters <= 0) return '0'
|
||||
return Number.isInteger(liters) ? String(liters) : liters.toFixed(1)
|
||||
}
|
||||
|
||||
export function carryOverTankLevelsFromPreviousDay(previousEntry?: LogEntryTankSource | null): { freshwater: TankLevels; fuel: TankLevels } {
|
||||
if (!previousEntry) {
|
||||
return { freshwater: emptyTankLevels(), fuel: emptyTankLevels() }
|
||||
}
|
||||
|
||||
return {
|
||||
freshwater: emptyTankLevels(getClosingTankLevel(previousEntry.freshwater)),
|
||||
fuel: emptyTankLevels(getClosingTankLevel(previousEntry.fuel))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export function isSignatureImage(value: string | undefined | null): boolean {
|
||||
return typeof value === 'string' && value.startsWith('data:image/')
|
||||
}
|
||||
|
||||
export function formatSignatureForExport(value: string | undefined | null): string {
|
||||
if (!value) return ''
|
||||
if (isSignatureImage(value)) return '[Unterschrift]'
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { TrackWaypoint } from '../services/trackUpload.js'
|
||||
|
||||
const NM_IN_METERS = 1852
|
||||
const MAX_PLAUSIBLE_KNOTS = 50
|
||||
const FALLBACK_GREEN = '#16a34a'
|
||||
|
||||
function haversineMeters(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
const R = 6371000
|
||||
const p1 = (lat1 * Math.PI) / 180
|
||||
const p2 = (lat2 * Math.PI) / 180
|
||||
const dLat = ((lat2 - lat1) * Math.PI) / 180
|
||||
const dLon = ((lon2 - lon1) * Math.PI) / 180
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(p1) * Math.cos(p2) * Math.sin(dLon / 2) ** 2
|
||||
return 2 * R * Math.asin(Math.sqrt(a))
|
||||
}
|
||||
|
||||
function hasMeaningfulTimestamps(waypoints: TrackWaypoint[]): boolean {
|
||||
if (waypoints.length < 2) return false
|
||||
const first = waypoints[0].timestamp
|
||||
const last = waypoints[waypoints.length - 1].timestamp
|
||||
return last > first + 60_000
|
||||
}
|
||||
|
||||
export function getSegmentSpeedsKn(waypoints: TrackWaypoint[]): number[] {
|
||||
if (waypoints.length < 2) return []
|
||||
|
||||
const timed = hasMeaningfulTimestamps(waypoints)
|
||||
const speeds: number[] = []
|
||||
|
||||
for (let i = 1; i < waypoints.length; i++) {
|
||||
const prev = waypoints[i - 1]
|
||||
const curr = waypoints[i]
|
||||
let speedKn = 0
|
||||
|
||||
const tagged = [prev.speedKnots, curr.speedKnots].filter(
|
||||
(value): value is number => value != null && value > 0
|
||||
)
|
||||
if (tagged.length > 0) {
|
||||
speedKn = tagged.reduce((sum, value) => sum + value, 0) / tagged.length
|
||||
} else if (timed) {
|
||||
const dtMs = curr.timestamp - prev.timestamp
|
||||
const segmentM = haversineMeters(prev.lat, prev.lng, curr.lat, curr.lng)
|
||||
if (dtMs > 0 && segmentM > 0) {
|
||||
speedKn = (segmentM / NM_IN_METERS) / (dtMs / 3_600_000)
|
||||
}
|
||||
}
|
||||
|
||||
if (speedKn > MAX_PLAUSIBLE_KNOTS) speedKn = 0
|
||||
speeds.push(speedKn)
|
||||
}
|
||||
|
||||
return speeds
|
||||
}
|
||||
|
||||
export function hasSpeedGradientData(speeds: number[]): boolean {
|
||||
const valid = speeds.filter((speed) => speed > 0)
|
||||
if (valid.length < 2) return false
|
||||
const min = Math.min(...valid)
|
||||
const max = Math.max(...valid)
|
||||
return max - min >= 0.3
|
||||
}
|
||||
|
||||
/** Green (slow) → yellow → red (fast) */
|
||||
export function speedToTrackColor(speedKn: number, minKn: number, maxKn: number): string {
|
||||
if (speedKn <= 0 || maxKn <= minKn) return FALLBACK_GREEN
|
||||
const t = Math.max(0, Math.min(1, (speedKn - minKn) / (maxKn - minKn)))
|
||||
const hue = 120 - t * 120
|
||||
return `hsl(${hue}, 72%, 42%)`
|
||||
}
|
||||
|
||||
export function getTrackLineColor(speeds: number[]): string {
|
||||
return hasSpeedGradientData(speeds) ? '' : FALLBACK_GREEN
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { TrackWaypoint } from '../services/trackUpload.js'
|
||||
|
||||
const NM_IN_METERS = 1852
|
||||
const MAX_PLAUSIBLE_KNOTS = 50
|
||||
|
||||
export interface TrackStats {
|
||||
distanceNm: number
|
||||
speedMaxKn: number
|
||||
speedAvgKn: number
|
||||
durationMinutes: number
|
||||
}
|
||||
|
||||
function haversineMeters(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
const R = 6371000
|
||||
const p1 = (lat1 * Math.PI) / 180
|
||||
const p2 = (lat2 * Math.PI) / 180
|
||||
const dLat = ((lat2 - lat1) * Math.PI) / 180
|
||||
const dLon = ((lon2 - lon1) * Math.PI) / 180
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(p1) * Math.cos(p2) * Math.sin(dLon / 2) ** 2
|
||||
return 2 * R * Math.asin(Math.sqrt(a))
|
||||
}
|
||||
|
||||
function hasMeaningfulTimestamps(waypoints: TrackWaypoint[]): boolean {
|
||||
if (waypoints.length < 2) return false
|
||||
const first = waypoints[0].timestamp
|
||||
const last = waypoints[waypoints.length - 1].timestamp
|
||||
return last > first + 60_000
|
||||
}
|
||||
|
||||
export function computeTrackStats(waypoints: TrackWaypoint[]): TrackStats | null {
|
||||
if (waypoints.length < 2) return null
|
||||
|
||||
let totalMeters = 0
|
||||
let maxSegmentKn = 0
|
||||
let maxTaggedKn = 0
|
||||
let hasTaggedSpeed = false
|
||||
|
||||
const timed = hasMeaningfulTimestamps(waypoints)
|
||||
const firstTs = waypoints[0].timestamp
|
||||
const lastTs = waypoints[waypoints.length - 1].timestamp
|
||||
|
||||
for (let i = 1; i < waypoints.length; i++) {
|
||||
const prev = waypoints[i - 1]
|
||||
const curr = waypoints[i]
|
||||
const segmentM = haversineMeters(prev.lat, prev.lng, curr.lat, curr.lng)
|
||||
totalMeters += segmentM
|
||||
|
||||
if (timed) {
|
||||
const dtMs = curr.timestamp - prev.timestamp
|
||||
if (dtMs > 0 && segmentM > 0) {
|
||||
const segmentKn = (segmentM / NM_IN_METERS) / (dtMs / 3_600_000)
|
||||
if (segmentKn <= MAX_PLAUSIBLE_KNOTS) {
|
||||
maxSegmentKn = Math.max(maxSegmentKn, segmentKn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (curr.speedKnots != null && curr.speedKnots > 0) {
|
||||
hasTaggedSpeed = true
|
||||
maxTaggedKn = Math.max(maxTaggedKn, curr.speedKnots)
|
||||
}
|
||||
}
|
||||
|
||||
const distanceNm = totalMeters / NM_IN_METERS
|
||||
if (distanceNm <= 0) return null
|
||||
|
||||
let speedMaxKn = 0
|
||||
let speedAvgKn = 0
|
||||
let durationMinutes = 0
|
||||
|
||||
if (timed) {
|
||||
const durationHours = (lastTs - firstTs) / 3_600_000
|
||||
durationMinutes = Math.round((lastTs - firstTs) / 60_000)
|
||||
speedAvgKn = durationHours > 0 ? distanceNm / durationHours : 0
|
||||
speedMaxKn = Math.max(maxSegmentKn, hasTaggedSpeed ? maxTaggedKn : 0)
|
||||
} else if (hasTaggedSpeed) {
|
||||
const taggedSpeeds = waypoints
|
||||
.map((wp) => wp.speedKnots)
|
||||
.filter((speed): speed is number => speed != null && speed > 0)
|
||||
speedMaxKn = maxTaggedKn
|
||||
speedAvgKn =
|
||||
taggedSpeeds.length > 0
|
||||
? taggedSpeeds.reduce((sum, speed) => sum + speed, 0) / taggedSpeeds.length
|
||||
: 0
|
||||
}
|
||||
|
||||
return {
|
||||
distanceNm: Number(distanceNm.toFixed(2)),
|
||||
speedMaxKn: Number(speedMaxKn.toFixed(1)),
|
||||
speedAvgKn: Number(speedAvgKn.toFixed(1)),
|
||||
durationMinutes
|
||||
}
|
||||
}
|
||||
|
||||
export function formatTrackStats(stats: TrackStats): {
|
||||
distanceNm: string
|
||||
speedMaxKn: string
|
||||
speedAvgKn: string
|
||||
} {
|
||||
return {
|
||||
distanceNm: stats.distanceNm.toFixed(2),
|
||||
speedMaxKn: stats.speedMaxKn > 0 ? stats.speedMaxKn.toFixed(1) : '',
|
||||
speedAvgKn: stats.speedAvgKn > 0 ? stats.speedAvgKn.toFixed(1) : ''
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,9 @@ export default defineConfig({
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(readAppVersion())
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['leaflet']
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
@@ -38,8 +41,8 @@ export default defineConfig({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['favicon.ico', 'logo.png'],
|
||||
manifest: {
|
||||
name: 'Kapteins Daagbox',
|
||||
short_name: 'Daagbox',
|
||||
name: 'Kapteins Daagbok',
|
||||
short_name: 'Daagbok',
|
||||
description: 'Digital maritime ship logbook with E2E encryption and Passkeys',
|
||||
theme_color: '#1e293b',
|
||||
background_color: '#0f172a',
|
||||
|
||||
@@ -5,7 +5,7 @@ COMPOSE_FILE="docker-compose.yml"
|
||||
BACKEND_CONTAINER="daagbox-prod-backend"
|
||||
|
||||
echo "=================================================="
|
||||
echo " Kapteins Daagbox Docker Environment Manager "
|
||||
echo " Kapteins Daagbok Docker Environment Manager "
|
||||
echo "=================================================="
|
||||
echo "Stopping any existing container stack..."
|
||||
docker compose -f $COMPOSE_FILE down
|
||||
|
||||
@@ -5,7 +5,7 @@ SERVER_PORT=5000
|
||||
CLIENT_PORT=5173
|
||||
|
||||
echo "========================================"
|
||||
echo " Kapteins Daagbox Dev Environment "
|
||||
echo " Kapteins Daagbok Dev Environment "
|
||||
echo "========================================"
|
||||
echo "Preparing to (re)start services..."
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ VERSION_FILE="$REPO_ROOT/VERSION"
|
||||
DEFAULT_VERSION="0.1.0.0"
|
||||
|
||||
echo "=================================================="
|
||||
echo " Kapteins Daagbox Prod Environment Update "
|
||||
echo " Kapteins Daagbok Prod Environment Update "
|
||||
echo "=================================================="
|
||||
echo "Target: ${REMOTE_TARGET}:${REMOTE_DIR}"
|
||||
echo "=================================================="
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "1.0.0",
|
||||
"description": "Backend API for Kapteins Daagbox",
|
||||
"description": "Backend API for Kapteins Daagbok",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+2
-2
@@ -29,7 +29,7 @@ app.get('/api/health', async (req, res) => {
|
||||
status: 'ok',
|
||||
database: 'connected',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'Kapteins Daagbox Backend'
|
||||
service: 'Kapteins Daagbok Backend'
|
||||
})
|
||||
} catch (err: any) {
|
||||
res.status(500).json({
|
||||
@@ -37,7 +37,7 @@ app.get('/api/health', async (req, res) => {
|
||||
database: 'disconnected',
|
||||
error: err.message,
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'Kapteins Daagbox Backend'
|
||||
service: 'Kapteins Daagbok Backend'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@ import { prisma } from '../db.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const rpName = 'Kapteins Daagbox'
|
||||
const rpName = 'Kapteins Daagbok'
|
||||
const rpID = process.env.RP_ID || 'localhost'
|
||||
const origin = process.env.ORIGIN || 'http://localhost:5173'
|
||||
|
||||
|
||||
+1274
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user