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:
+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>
|
||||
|
||||
Generated
-25
@@ -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",
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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: '© <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 +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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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…"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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…"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user