fix: Daagbok-Branding, Track-Upload statt GPS-Tracker und zentrale Account-Löschung.

Ersetzt gpsTracker/Leaflet durch trackUpload, korrigiert App-Bezeichner und Login-Abstände, und macht die Account-Gefahrenzone auf dem Dashboard erreichbar.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-29 15:25:39 +02:00
parent 603d0bf1c4
commit 3c7aec1573
21 changed files with 261 additions and 367 deletions
+1 -1
View File
@@ -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>
-25
View File
@@ -15,7 +15,6 @@
"i18next": "^26.3.0",
"i18next-browser-languagedetector": "^8.2.1",
"jspdf": "^4.2.1",
"leaflet": "^1.9.4",
"lucide-react": "^1.16.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
@@ -23,7 +22,6 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/leaflet": "^1.9.21",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@@ -2717,13 +2715,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -2731,16 +2722,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/leaflet": {
"version": "1.9.21",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/node": {
"version": "24.12.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz",
@@ -5318,12 +5299,6 @@
"json-buffer": "3.0.1"
}
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/leven": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
-2
View File
@@ -17,7 +17,6 @@
"i18next": "^26.3.0",
"i18next-browser-languagedetector": "^8.2.1",
"jspdf": "^4.2.1",
"leaflet": "^1.9.4",
"lucide-react": "^1.16.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
@@ -25,7 +24,6 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/leaflet": "^1.9.21",
"@types/node": "^24.12.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
+49 -13
View File
@@ -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);
@@ -1894,16 +1940,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;
@@ -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>
)
}
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -275,7 +275,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)}
/>
)
}
+52 -158
View File
@@ -6,20 +6,17 @@ 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 { useDialog } from './ModalDialog.tsx'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import {
getDecryptedGpsTrack,
saveUploadedGpsTrack,
deleteGpsTrack,
getDecryptedTrack,
saveUploadedTrack,
deleteTrack,
downloadTrackFile,
parseTrackFile,
type GpsWaypoint,
type SavedGpsTrack
} from '../services/gpsTracker.js'
type SavedTrack
} from '../services/trackUpload.js'
interface LogEntryEditorProps {
entryId: string
@@ -28,7 +25,7 @@ interface LogEntryEditorProps {
readOnly?: boolean
preloadedEntry?: any
preloadedPhotos?: any[]
preloadedGpsTrack?: any
preloadedTrack?: any
preloadedYacht?: any
}
@@ -58,7 +55,7 @@ export default function LogEntryEditor({
readOnly = false,
preloadedEntry,
preloadedPhotos,
preloadedGpsTrack,
preloadedTrack,
preloadedYacht
}: LogEntryEditorProps) {
const { t, i18n } = useTranslation()
@@ -116,15 +113,12 @@ 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)
// Auto-calculate Freshwater Consumption
useEffect(() => {
const morning = parseFloat(fwMorning) || 0
@@ -236,91 +230,24 @@ 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
}
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: '&copy; <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 &copy; <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 +265,8 @@ 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)
await loadTrack()
} catch (err: any) {
console.error('File parsing failed:', err)
setUploadError(err.message || 'Failed to parse track file.')
@@ -380,7 +307,7 @@ export default function LogEntryEditor({
return
}
try {
await deleteGpsTrack(logbookId, entryId)
await deleteTrack(logbookId, entryId)
setSavedTrack(null)
setUploadError(null)
} catch (err: any) {
@@ -388,30 +315,6 @@ export default function LogEntryEditor({
}
}
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 () => {
@@ -1234,17 +1137,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,52 +1162,44 @@ 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' }} />
<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>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<div className="track-info-header">
<div className="track-info-left">
<Upload size={16} style={{ color: '#fbbf24' }} />
<span className="track-info-name">{savedTrack.filename || 'track'}</span>
<span className="track-info-stats">
{savedTrack.fileType.toUpperCase()}
{savedTrack.waypoints.length > 0 && (
<> · {savedTrack.waypoints.length} {t('logs.track_upload_points')}</>
)}
</span>
</div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button
type="button"
className="btn secondary"
onClick={() => downloadTrackFile(savedTrack)}
style={{ width: 'auto', padding: '6px 12px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '4px' }}
>
<Download size={14} />
{t('logs.gps_tracking_btn_gpx')}
</button>
{!readOnly && (
<button
type="button"
className="btn secondary"
onClick={() => downloadTrackFile(savedTrack)}
style={{ width: 'auto', padding: '6px 12px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '4px' }}
onClick={handleDeleteTrack}
style={{ width: 'auto', padding: '6px 12px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '4px', background: 'rgba(239, 68, 68, 0.1)', color: '#ef4444', borderColor: 'rgba(239, 68, 68, 0.2)' }}
>
<Download size={14} />
{t('logs.gps_tracking_btn_gpx')}
<Trash2 size={14} />
{t('logs.gps_track_delete')}
</button>
{!readOnly && (
<button
type="button"
className="btn secondary"
onClick={handleDeleteTrack}
style={{ width: 'auto', padding: '6px 12px', fontSize: '13px', display: 'flex', alignItems: 'center', gap: '4px', background: 'rgba(239, 68, 68, 0.1)', color: '#ef4444', borderColor: 'rgba(239, 68, 68, 0.2)' }}
>
<Trash2 size={14} />
{t('logs.gps_track_delete')}
</button>
)}
</div>
)}
</div>
{/* Leaflet Map Div */}
<div id="openseamap-container" ref={mapContainerRef} />
</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>
)
}
+4 -52
View File
@@ -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>
)
}
+7 -7
View File
@@ -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",
@@ -157,10 +157,9 @@
"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",
@@ -253,7 +252,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…"
}
}
}
+8 -8
View File
@@ -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”",
@@ -157,10 +157,9 @@
"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",
@@ -253,7 +252,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 -1
View File
@@ -166,7 +166,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) {
@@ -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>
+2 -2
View File
@@ -38,8 +38,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',
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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..."
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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'
})
}
})
+1 -1
View File
@@ -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'