Compare commits

..

13 Commits

Author SHA1 Message Date
elpatron 2a8ec2fccf chore: release v0.1.0.76 2026-06-01 11:43:33 +02:00
elpatron 60a8533a44 feat: add Plausible events for live log photos and OWM usage
Track Live Log Photo Uploaded and centralize OWM Weather Fetched with
source props for live log and entry editor call sites.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 11:34:21 +02:00
elpatron c86ac4273c chore: release v0.1.0.75 2026-06-01 10:59:31 +02:00
elpatron 73467f2263 fix: live journal camera save on Android
Use native camera picker on mobile, add preview-and-save step, and
harden canvas capture with toDataURL fallback when toBlob fails.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 10:45:54 +02:00
elpatron e068f083c1 style: add blank line before LiveLogViewProps interface
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 10:01:34 +02:00
elpatron f083294db5 chore: release v0.1.0.74 2026-06-01 09:58:44 +02:00
elpatron 8fc15081e2 Show QR codes for invite and public share links.
Generate scannable QR codes in settings next to collaboration links so crew can open invites on mobile without copying long URLs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 09:58:16 +02:00
elpatron efa0fcf934 Add live journal camera photos and harden OWM button.
Capture photos via getUserMedia in live log, share encrypted save logic with the editor, and disable weather fetch while other quick actions run.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 09:47:56 +02:00
elpatron c1ecdcad9c Fix live journal hang on empty new logbooks.
Fast-path today's entry creation, add init timeout, defer auto-position GPS, and migrate logbook keys when the server returns a different id.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 09:39:51 +02:00
elpatron d6c7952af8 Fix live journal freeze during OpenWeatherMap fetch.
Batch weather events in one persist cycle, avoid global busy state while loading, and add a 20s API timeout.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 09:19:03 +02:00
elpatron 3d02f841a0 Add live journal OWM weather and manual GPS fix dialog.
Fetch OpenWeatherMap in live log when a GPS fix is under six hours old, open a fix modal with manual coordinates when geolocation fails, and only show the undo bar after a successful save.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 09:03:37 +02:00
elpatron 0caaf681d8 Fix live journal freeze and passkey login on localhost.
Harden live log init with safe per-entry decrypt, stable loading state, and no parallel list scan in live mode. Improve multi-sail picker UX, stop WebAuthn retry after user cancel, redirect 127.0.0.1 to localhost, and tolerate missing appearance prefs table.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 08:49:45 +02:00
elpatron 43dc994c4f Update beta flyer with NMEA, live log, and route stats features.
Add three feature bullets in all locales, reduce feature list font to 8.5pt for single-page layout, and regenerate PDF/PNG exports.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 21:52:11 +02:00
57 changed files with 2414 additions and 348 deletions
+4 -4
View File
@@ -5,13 +5,13 @@ OpenWeatherMapAPIKey=<owm_api_key>
DeepLAPIKey=
# Passkey configuration (WebAuthn Relying Party ID and Origin)
# For local dev: localhost and http://localhost
# For local dev: use localhost (NOT 127.0.0.1 — browsers reject IP addresses for Passkeys)
# For production: e.g. kapteins-daagbok.eu and https://kapteins-daagbok.eu
RP_ID=localhost
# Must match the frontend URL (Vite dev: http://localhost:5173; Docker: http://localhost)
# Must match the frontend URL exactly (Vite dev: http://localhost:5173; Docker: http://localhost)
ORIGIN=http://localhost:5173
# Optional: comma-separated CORS origins (defaults to ORIGIN; dev also allows 127.0.0.1:5173)
# CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
# Optional: comma-separated CORS origins (defaults to ORIGIN; 127.0.0.1 may be allowed for CORS but not for login)
# CORS_ORIGINS=http://localhost:5173
# API session signing (min. 32 chars; required in production)
# Generate: openssl rand -base64 48
+1 -1
View File
@@ -1 +1 @@
0.1.0.74
0.1.0.77
+12 -30
View File
@@ -17,6 +17,7 @@
"jspdf": "^4.2.1",
"leaflet": "^1.9.4",
"lucide-react": "^1.16.0",
"qrcode": "^1.5.4",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-i18next": "^17.0.8"
@@ -25,6 +26,7 @@
"@eslint/js": "^10.0.1",
"@types/leaflet": "^1.9.21",
"@types/node": "^24.12.3",
"@types/qrcode": "^1.5.5",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.7.0",
@@ -34,7 +36,6 @@
"globals": "^17.6.0",
"happy-dom": "^20.9.0",
"playwright": "^1.51.0",
"qrcode": "^1.5.4",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^6.3.5",
@@ -2970,6 +2971,16 @@
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
"license": "MIT"
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/raf": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
@@ -3461,7 +3472,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -3471,7 +3481,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -3777,7 +3786,6 @@
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -3855,7 +3863,6 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
@@ -3867,7 +3874,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -3880,7 +3886,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/commander": {
@@ -4051,7 +4056,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -4140,7 +4144,6 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"dev": true,
"license": "MIT"
},
"node_modules/dompurify": {
@@ -4195,7 +4198,6 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/entities": {
@@ -4948,7 +4950,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
@@ -5498,7 +5499,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -6208,7 +6208,6 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -6231,7 +6230,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -6376,7 +6374,6 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.13.0"
@@ -6458,7 +6455,6 @@
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
@@ -6653,7 +6649,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -6673,7 +6668,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"dev": true,
"license": "ISC"
},
"node_modules/resolve": {
@@ -6845,7 +6839,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"dev": true,
"license": "ISC"
},
"node_modules/set-function-length": {
@@ -7113,7 +7106,6 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
@@ -7230,7 +7222,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
@@ -8067,7 +8058,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"dev": true,
"license": "ISC"
},
"node_modules/which-typed-array": {
@@ -8343,7 +8333,6 @@
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
@@ -8380,7 +8369,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"dev": true,
"license": "ISC"
},
"node_modules/yallist": {
@@ -8394,7 +8382,6 @@
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
@@ -8417,7 +8404,6 @@
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
@@ -8431,7 +8417,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
@@ -8445,7 +8430,6 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
@@ -8458,7 +8442,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
@@ -8474,7 +8457,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
+3 -2
View File
@@ -29,12 +29,14 @@
"lucide-react": "^1.16.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-i18next": "^17.0.8"
"react-i18next": "^17.0.8",
"qrcode": "^1.5.4"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/leaflet": "^1.9.21",
"@types/node": "^24.12.3",
"@types/qrcode": "^1.5.5",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.7.0",
@@ -44,7 +46,6 @@
"globals": "^17.6.0",
"happy-dom": "^20.9.0",
"playwright": "^1.51.0",
"qrcode": "^1.5.4",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^6.3.5",
+213 -3
View File
@@ -1716,6 +1716,38 @@ html.scheme-dark .themed-select-option.is-selected {
min-width: 0;
}
.link-with-qr {
display: flex;
flex-direction: column;
gap: 16px;
}
.link-qr-block {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px;
border-radius: var(--app-radius-card, 12px);
background: var(--app-surface-inset, rgba(0, 0, 0, 0.2));
border: 1px solid var(--app-border-subtle);
}
.link-qr-label {
margin: 0;
font-size: 13px;
color: var(--app-text-muted);
text-align: center;
}
.link-qr-image {
display: block;
border-radius: 8px;
background: #fff;
padding: 8px;
box-sizing: content-box;
}
.form-actions--start {
justify-content: flex-start;
}
@@ -3345,7 +3377,14 @@ html.theme-cupertino .events-scroll-container {
}
.live-log-sail-pills {
margin-bottom: 16px;
margin-bottom: 12px;
}
.live-log-sails-selection {
margin: 0 0 12px;
font-size: 13px;
color: var(--app-accent-light, #93c5fd);
line-height: 1.4;
}
.live-log-modal-actions {
@@ -3413,6 +3452,12 @@ html.theme-cupertino .events-scroll-container {
cursor: pointer;
}
.live-log-subaction-btn-owm {
border-color: rgba(59, 130, 246, 0.35);
color: var(--app-text);
font-weight: 500;
}
.live-log-subaction-btn:hover:not(:disabled) {
color: var(--app-text);
border-color: rgba(59, 130, 246, 0.3);
@@ -3420,11 +3465,18 @@ html.theme-cupertino .events-scroll-container {
.live-log-undo-bar {
position: fixed;
left: 50%;
inset-inline: 0;
bottom: 24px;
transform: translateX(-50%);
z-index: 10060;
display: flex;
justify-content: center;
padding-inline: 16px;
pointer-events: none;
}
.live-log-undo-bar-inner {
pointer-events: auto;
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
@@ -3433,6 +3485,164 @@ html.theme-cupertino .events-scroll-container {
border: 1px solid var(--app-border-muted);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
font-size: 14px;
max-width: min(100%, 420px);
}
.live-log-fix-coords {
margin: 0;
padding: 0;
border: none;
min-width: 0;
}
.live-log-fix-label {
display: block;
margin: 0 0 10px;
padding: 0;
font-size: 13px;
font-weight: 600;
color: var(--app-text-muted);
}
.live-log-fix-coords-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
min-width: 0;
}
.live-log-fix-field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.live-log-fix-field-label {
font-size: 12px;
color: var(--app-text-muted);
}
.live-log-fix-field .input-text {
width: 100%;
box-sizing: border-box;
}
.live-log-fix-gps-row {
margin-top: 10px;
}
.live-log-fix-gps-btn {
width: 100%;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 14px;
}
.live-camera-modal {
width: min(480px, 100%);
}
.live-camera-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.live-camera-header h3 {
margin: 0;
}
.live-camera-close {
width: auto;
padding: 8px 10px;
}
.live-camera-preview-wrap {
position: relative;
width: 100%;
aspect-ratio: 4 / 3;
border-radius: var(--app-radius-input, 8px);
overflow: hidden;
background: #000;
margin-bottom: 12px;
}
.live-camera-preview {
width: 100%;
height: 100%;
object-fit: cover;
}
.live-camera-loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
margin: 0;
padding: 12px;
text-align: center;
font-size: 14px;
color: var(--app-text-muted);
background: rgba(0, 0, 0, 0.45);
}
.live-camera-caption {
margin-bottom: 12px;
}
.live-camera-actions {
margin-top: 0;
}
.live-camera-shutter {
display: inline-flex;
align-items: center;
gap: 8px;
}
.live-camera-file-input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.live-camera-preview-still {
display: block;
object-fit: contain;
background: #000;
}
.live-camera-native-prompt {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 12px;
margin-bottom: 12px;
}
.live-camera-open-native {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
}
.live-camera-actions {
flex-wrap: wrap;
}
.stats-event-series-block + .stats-event-series-block {
+76 -18
View File
@@ -1,21 +1,28 @@
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
import {
registerUser,
loginUser,
completeLoginWithRecovery,
setLocalPin,
hasLocalPin,
decryptWithLocalPin,
import {
registerUser,
loginUser,
completeLoginWithRecovery,
setLocalPin,
hasLocalPin,
decryptWithLocalPin,
getActiveMasterKey,
getKnownUsernames,
forgetUsername
forgetUsername,
hasUnlockedLocalSession,
logoutUser
} from '../services/auth.js'
import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react'
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
import DisclaimerModal from './DisclaimerModal.tsx'
import BetaBadge from './BetaBadge.tsx'
import {
isPasskeyCompatibleLocation,
localizeWebAuthnError,
toPasskeyCompatibleUrl
} from '../utils/passkeyHost.ts'
interface AuthOnboardingProps {
onAuthenticated: () => void
@@ -54,6 +61,16 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
const [showDisclaimer, setShowDisclaimer] = useState(false)
const [showHelp, setShowHelp] = useState(false)
const passkeyHostOk = isPasskeyCompatibleLocation()
const passkeyCompatibleUrl = passkeyHostOk ? null : toPasskeyCompatibleUrl(window.location.href)
const formatAuthError = (message: string) =>
localizeWebAuthnError(message, {
invalidHost: t('auth.error_invalid_host'),
cancelled: t('auth.error_passkey_cancelled'),
invalidRpId: t('auth.error_invalid_rp_id')
})
const finishAuth = () => {
if (isNewRegistration) {
setShowDisclaimer(true)
@@ -81,7 +98,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
setRecoveryPhrase(result.recoveryPhrase)
}
} catch (err: any) {
setError(err.message || 'Registration failed')
setError(formatAuthError(err.message || 'Registration failed'))
} finally {
setLoading(false)
}
@@ -121,7 +138,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
}
}
} catch (err: any) {
setError(err.message || 'Login failed')
setError(formatAuthError(err.message || 'Login failed'))
} finally {
setLoading(false)
}
@@ -185,19 +202,33 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
const handlePinLoginSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!pinLoginInput.trim()) return
if (!pinLoginInput.trim() || loading) return
const resolvedUser =
username.trim() ||
encryptedPayloads?.username ||
localStorage.getItem('active_username') ||
''
if (!resolvedUser) {
setError(t('auth.error_session_incomplete'))
return
}
setLoading(true)
setError(null)
try {
const resolvedUser = username.trim() || encryptedPayloads?.username
const key = await decryptWithLocalPin(pinLoginInput.trim(), resolvedUser)
if (key) {
onAuthenticated()
} else {
if (!key) {
setError(t('auth.error_incorrect_pin'))
return
}
} catch (err: any) {
if (!hasUnlockedLocalSession()) {
setError(t('auth.error_session_incomplete'))
return
}
setShowPinLogin(false)
onAuthenticated()
} catch {
setError(t('auth.error_incorrect_pin'))
} finally {
setLoading(false)
@@ -361,6 +392,24 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
>
{t('auth.use_recovery_instead')}
</button>
<button
type="button"
className="btn secondary"
onClick={() => {
void (async () => {
setShowPinLogin(false)
setPinLoginInput('')
setEncryptedPayloads(null)
setError(null)
await logoutUser()
})()
}}
disabled={loading}
style={{ width: '100%' }}
>
{t('auth.back')}
</button>
</div>
</form>
</div>
@@ -445,12 +494,21 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
</div>
<div className="auth-form" style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: '20px' }}>
{!passkeyHostOk && passkeyCompatibleUrl && (
<div className="auth-error" role="alert">
<p style={{ margin: '0 0 8px' }}>{t('auth.error_invalid_host')}</p>
<a href={passkeyCompatibleUrl} className="btn secondary" style={{ display: 'inline-block', textDecoration: 'none' }}>
{t('auth.use_localhost_link')}
</a>
</div>
)}
{/* Prominent Login button */}
<button
type="button"
className="btn primary"
onClick={() => handleLogin()}
disabled={loading}
disabled={loading || !passkeyHostOk}
style={{ width: '100%', padding: '16px' }}
>
{loading
@@ -583,7 +641,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
<button
type="submit"
className="btn secondary"
disabled={loading || !username.trim()}
disabled={loading || !username.trim() || !passkeyHostOk}
style={{ width: '100%' }}
>
{t('auth.register')}
+54
View File
@@ -0,0 +1,54 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import QRCode from 'qrcode'
interface LinkQrCodeProps {
value: string
size?: number
}
export default function LinkQrCode({ value, size = 200 }: LinkQrCodeProps) {
const { t } = useTranslation()
const [dataUrl, setDataUrl] = useState<string | null>(null)
useEffect(() => {
if (!value.trim()) {
setDataUrl(null)
return
}
let cancelled = false
void QRCode.toDataURL(value, {
width: size,
margin: 2,
errorCorrectionLevel: 'M',
color: { dark: '#0f172a', light: '#ffffff' }
})
.then((url) => {
if (!cancelled) setDataUrl(url)
})
.catch((err) => {
console.error('QR code generation failed:', err)
if (!cancelled) setDataUrl(null)
})
return () => {
cancelled = true
}
}, [value, size])
if (!value.trim() || !dataUrl) return null
return (
<div className="link-qr-block">
<p className="link-qr-label">{t('settings.link_qr_hint')}</p>
<img
src={dataUrl}
width={size}
height={size}
className="link-qr-image"
alt={t('settings.link_qr_alt')}
/>
</div>
)
}
+323
View File
@@ -0,0 +1,323 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Camera, X } from 'lucide-react'
import {
captureVideoFrame,
preferNativeCameraPicker
} from '../utils/captureVideoFrame.js'
interface LiveCameraCaptureProps {
open: boolean
busy?: boolean
caption?: string
onCaptionChange?: (value: string) => void
onClose: () => void
onCapture: (blob: Blob) => void
}
type Phase = 'live' | 'preview' | 'native'
export default function LiveCameraCapture({
open,
busy = false,
caption = '',
onCaptionChange,
onClose,
onCapture
}: LiveCameraCaptureProps) {
const { t } = useTranslation()
const videoRef = useRef<HTMLVideoElement | null>(null)
const fileInputRef = useRef<HTMLInputElement | null>(null)
const streamRef = useRef<MediaStream | null>(null)
const previewUrlRef = useRef<string | null>(null)
const [cameraError, setCameraError] = useState<string | null>(null)
const [ready, setReady] = useState(false)
const [capturing, setCapturing] = useState(false)
const [phase, setPhase] = useState<Phase>(() => (preferNativeCameraPicker() ? 'native' : 'live'))
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const [previewBlob, setPreviewBlob] = useState<Blob | null>(null)
const [streamGeneration, setStreamGeneration] = useState(0)
const clearPreview = useCallback(() => {
if (previewUrlRef.current) {
URL.revokeObjectURL(previewUrlRef.current)
previewUrlRef.current = null
}
setPreviewUrl(null)
setPreviewBlob(null)
}, [])
const stopStream = useCallback(() => {
for (const track of streamRef.current?.getTracks() ?? []) {
track.stop()
}
streamRef.current = null
if (videoRef.current) {
videoRef.current.srcObject = null
}
setReady(false)
}, [])
const enterPreview = useCallback((blob: Blob) => {
stopStream()
clearPreview()
const url = URL.createObjectURL(blob)
previewUrlRef.current = url
setPreviewBlob(blob)
setPreviewUrl(url)
setPhase('preview')
}, [stopStream, clearPreview])
const resetToLive = useCallback(() => {
clearPreview()
setCameraError(null)
setCapturing(false)
if (preferNativeCameraPicker()) {
setPhase('native')
} else {
setPhase('live')
setStreamGeneration((n) => n + 1)
}
}, [clearPreview])
useEffect(() => {
if (!open) {
stopStream()
clearPreview()
setCameraError(null)
setCapturing(false)
setPhase(preferNativeCameraPicker() ? 'native' : 'live')
return
}
setPhase(preferNativeCameraPicker() ? 'native' : 'live')
clearPreview()
}, [open, stopStream, clearPreview])
useEffect(() => {
if (!open || phase !== 'live') {
stopStream()
return
}
let cancelled = false
const start = async () => {
setCameraError(null)
setReady(false)
if (!navigator.mediaDevices?.getUserMedia) {
setCameraError(t('logs.live_photo_camera_unavailable'))
return
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: { ideal: 'environment' },
width: { ideal: 1280 },
height: { ideal: 720 }
},
audio: false
})
if (cancelled) {
for (const track of stream.getTracks()) track.stop()
return
}
streamRef.current = stream
const video = videoRef.current
if (!video) return
const markReady = () => {
if (cancelled) return
if (video.videoWidth > 0 && video.videoHeight > 0) {
setReady(true)
}
}
video.onloadedmetadata = markReady
video.srcObject = stream
await video.play()
markReady()
} catch (err) {
console.error('Camera access failed:', err)
if (!cancelled) {
setCameraError(t('logs.live_photo_camera_denied'))
}
}
}
void start()
return () => {
cancelled = true
stopStream()
}
}, [open, phase, streamGeneration, stopStream, t])
const handleCapture = async () => {
const video = videoRef.current
if (!video || !ready || busy || capturing) return
setCapturing(true)
setCameraError(null)
try {
const blob = await captureVideoFrame(video)
enterPreview(blob)
} catch (err) {
console.error('Live camera capture failed:', err)
setCameraError(t('logs.live_photo_capture_failed'))
} finally {
setCapturing(false)
}
}
const handleNativeFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
e.target.value = ''
if (!file || busy) return
setCameraError(null)
try {
enterPreview(file)
} catch (err) {
console.error('Live camera file pick failed:', err)
setCameraError(t('logs.live_photo_capture_failed'))
}
}
const handleSave = () => {
if (!previewBlob || busy) return
onCapture(previewBlob)
}
const handleRetake = () => {
if (busy) return
resetToLive()
}
const openNativePicker = () => {
if (busy) return
fileInputRef.current?.click()
}
if (!open) return null
const showPreview = phase === 'preview' && previewUrl
return (
<div
className="live-log-modal-backdrop live-camera-backdrop"
onClick={(e) => { if (e.target === e.currentTarget && !busy) onClose() }}
>
<div className="live-log-modal live-camera-modal" onClick={(e) => e.stopPropagation()}>
<div className="live-camera-header">
<h3>{t('logs.live_photo_btn')}</h3>
<button
type="button"
className="btn secondary live-camera-close"
onClick={onClose}
disabled={busy}
aria-label={t('logs.confirm_no')}
>
<X size={18} />
</button>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
capture="environment"
className="live-camera-file-input"
onChange={(e) => void handleNativeFile(e)}
/>
{cameraError && (
<p className="live-log-modal-hint auth-error">{cameraError}</p>
)}
{showPreview ? (
<div className="live-camera-preview-wrap">
<img
src={previewUrl}
alt=""
className="live-camera-preview live-camera-preview-still"
/>
</div>
) : phase === 'native' ? (
<div className="live-camera-native-prompt">
<p className="live-log-modal-hint">{t('logs.live_photo_native_hint')}</p>
<button
type="button"
className="btn primary live-camera-open-native"
onClick={openNativePicker}
disabled={busy}
>
<Camera size={18} />
{t('logs.live_photo_open_camera_btn')}
</button>
</div>
) : cameraError && !ready ? null : (
<div className="live-camera-preview-wrap">
<video
ref={videoRef}
className="live-camera-preview"
playsInline
muted
autoPlay
/>
{!ready && (
<p className="live-camera-loading">{t('logs.live_photo_camera_starting')}</p>
)}
</div>
)}
{onCaptionChange && (
<div className="input-group live-camera-caption">
<label>{t('logs.photo_caption_label')}</label>
<input
type="text"
className="input-text"
placeholder={t('logs.photo_caption_placeholder')}
value={caption}
onChange={(e) => onCaptionChange(e.target.value)}
disabled={busy}
/>
</div>
)}
<div className="live-log-modal-actions live-camera-actions">
<button type="button" className="btn secondary" onClick={onClose} disabled={busy}>
{t('logs.confirm_no')}
</button>
{showPreview ? (
<>
<button type="button" className="btn secondary" onClick={handleRetake} disabled={busy}>
{t('logs.live_photo_retake_btn')}
</button>
<button
type="button"
className="btn primary live-camera-shutter"
onClick={handleSave}
disabled={busy || !previewBlob}
>
<Camera size={18} />
{busy ? t('logs.photo_processing') : t('logs.live_photo_save_btn')}
</button>
</>
) : phase === 'native' ? null : (
<button
type="button"
className="btn primary live-camera-shutter"
onClick={() => void handleCapture()}
disabled={busy || capturing || !ready || !!cameraError}
>
<Camera size={18} />
{capturing ? t('logs.photo_processing') : t('logs.live_photo_capture_btn')}
</button>
)}
</div>
</div>
</div>
)
}
+473 -83
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import {
@@ -14,18 +14,19 @@ import {
Gauge,
MapPin,
MessageSquare,
Camera,
Radio,
Sailboat,
Undo2,
Zap
} from 'lucide-react'
import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js'
import { getLogbookKey } from '../services/logbookKeys.js'
import { decryptJson } from '../services/crypto.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import {
appendQuickEvent,
appendQuickEvents,
appendTankRefill,
findOrCreateTodayEntry,
loadEntry,
@@ -34,10 +35,14 @@ import {
import { formatEventSummary } from '../utils/formatEventSummary.js'
import {
getLastAutoPositionMs,
getLastPositionFixWithin,
getLatestPositionFix,
isMotorRunningFromEvents,
LIVE_EVENT_CODES,
LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS,
liveCommentRemark,
liveFuelRemark,
livePhotoRemark,
livePrecipRemark,
liveSailsRemark,
liveSogRemark,
@@ -45,10 +50,21 @@ import {
liveTempRemark,
liveWaterRemark
} from '../utils/liveEventCodes.js'
import { getCurrentPosition } from '../utils/geolocation.js'
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
import { getCurrentPosition, normalizeGpsCoordinates } from '../utils/geolocation.js'
import { sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayload.js'
import {
dedupeSailNames,
isSailInSelection,
joinSailSelection,
toggleSailInSelection
} from '../utils/sailSelection.js'
import { useDialog } from './ModalDialog.tsx'
import CourseDialInput from './CourseDialInput.tsx'
import LiveCameraCapture from './LiveCameraCapture.tsx'
import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.js'
import { blobToCompressedJpegDataUrl } from '../utils/imageCompress.js'
interface LiveLogViewProps {
logbookId: string
@@ -70,11 +86,31 @@ type LiveModal =
| 'water'
| 'sog'
| 'stw'
| 'fix'
| 'photo'
const AUTO_POSITION_INTERVAL_MS = 3 * 60 * 60 * 1000
const AUTO_POSITION_CHECK_MS = 60_000
const AUTO_POSITION_START_DELAY_MS = 3000
const LIVE_LOG_INIT_TIMEOUT_MS = 25_000
const UNDO_TIMEOUT_MS = 5000
function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
return new Promise((resolve, reject) => {
const timer = window.setTimeout(() => reject(new Error(message)), ms)
promise.then(
(value) => {
window.clearTimeout(timer)
resolve(value)
},
(err) => {
window.clearTimeout(timer)
reject(err)
}
)
})
}
function hapticPulse() {
navigator.vibrate?.(40)
}
@@ -113,77 +149,134 @@ export default function LiveLogView({
const [error, setError] = useState<string | null>(null)
const [modal, setModal] = useState<LiveModal>('none')
const [weatherExpanded, setWeatherExpanded] = useState(false)
const [weatherOwmLoading, setWeatherOwmLoading] = useState(false)
const [commentText, setCommentText] = useState('')
const [valueInput, setValueInput] = useState('')
const [valueInputSecondary, setValueInputSecondary] = useState('')
const [selectedSails, setSelectedSails] = useState<string[]>([])
const [undoVisible, setUndoVisible] = useState(false)
const [fixLat, setFixLat] = useState('')
const [fixLng, setFixLng] = useState('')
const [fixGpsLoading, setFixGpsLoading] = useState(false)
const [fixGpsUnavailable, setFixGpsUnavailable] = useState(false)
const [photoCaption, setPhotoCaption] = useState('')
const [photoSaving, setPhotoSaving] = useState(false)
const [undoHint, setUndoHint] = useState<'event' | 'photo'>('event')
const streamEndRef = useRef<HTMLDivElement | null>(null)
const undoPhotoIdRef = useRef<string | null>(null)
const undoTimerRef = useRef<number | null>(null)
const autoPositionBusyRef = useRef(false)
const initSeqRef = useRef(0)
const eventsRef = useRef(events)
const dateRef = useRef(date)
eventsRef.current = events
dateRef.current = date
const defaultSails = i18n.language === 'de'
? ['Großsegel', 'Genua', 'Fock', 'Spinnaker', 'Gennaker']
: ['Mainsail', 'Genoa', 'Jib', 'Spinnaker', 'Gennaker']
const sailOptions = yachtSails.length > 0 ? yachtSails : defaultSails
const defaultSails = useMemo(
() => (i18n.language === 'de'
? ['Großsegel', 'Genua', 'Fock', 'Spinnaker', 'Gennaker']
: ['Mainsail', 'Genoa', 'Jib', 'Spinnaker', 'Gennaker']),
[i18n.language]
)
const sailOptions = useMemo(
() => dedupeSailNames(yachtSails.length > 0 ? yachtSails : defaultSails),
[yachtSails, defaultSails]
)
const motorRunning = isMotorRunningFromEvents(events)
const motorLabel = t('logs.motor_propulsion')
const refreshEntry = useCallback(async (id: string) => {
const loaded = await loadEntry(logbookId, id)
if (!loaded) return
const applyLoadedEntry = useCallback((loaded: NonNullable<Awaited<ReturnType<typeof loadEntry>>>) => {
const entryEvents = (loaded.data.events as LogEventPayload[]) || []
setDayOfTravel(String(loaded.data.dayOfTravel || ''))
setDate(String(loaded.data.date || ''))
setEvents(sortLogEventsByTime(entryEvents.map((e) => ({ ...e }))))
}, [logbookId])
}, [])
const showUndo = useCallback(() => {
const refreshEntry = useCallback(async (id: string) => {
const loaded = await loadEntry(logbookId, id)
if (!loaded) return
applyLoadedEntry(loaded)
}, [logbookId, applyLoadedEntry])
const showUndo = useCallback((hint: 'event' | 'photo' = 'event') => {
setUndoHint(hint)
setUndoVisible(true)
if (undoTimerRef.current) window.clearTimeout(undoTimerRef.current)
undoTimerRef.current = window.setTimeout(() => {
setUndoVisible(false)
undoTimerRef.current = null
undoPhotoIdRef.current = null
}, UNDO_TIMEOUT_MS)
}, [])
useEffect(() => {
let cancelled = false
const runInit = useCallback(async () => {
const seq = ++initSeqRef.current
setLoading(true)
setError(null)
setEntryId(null)
setEvents([])
setYachtSails([])
async function init() {
setLoading(true)
setError(null)
try {
const id = await findOrCreateTodayEntry(logbookId)
if (cancelled) return
setEntryId(id)
if (!logbookId.trim()) {
setError(t('logs.live_load_error'))
setLoading(false)
return
}
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (masterKey) {
const yacht = await db.yachts.get(logbookId)
if (yacht) {
const decrypted = await decryptJson(yacht.encryptedData, yacht.iv, yacht.tag, masterKey)
try {
const id = await withTimeout(
findOrCreateTodayEntry(logbookId),
LIVE_LOG_INIT_TIMEOUT_MS,
t('logs.live_load_error')
)
if (seq !== initSeqRef.current) return
setEntryId(id)
const logbookKey = await getLogbookKey(logbookId)
if (logbookKey) {
const yacht = await db.yachts.get(logbookId)
if (yacht) {
try {
const decrypted = await decryptJson(
yacht.encryptedData,
yacht.iv,
yacht.tag,
logbookKey
)
if (decrypted?.sails && Array.isArray(decrypted.sails)) {
setYachtSails(decrypted.sails as string[])
}
} catch {
// Yacht profile optional for live log
}
}
}
await refreshEntry(id)
} catch (err: unknown) {
if (!cancelled) {
console.error('Failed to init live log:', err)
setError(err instanceof Error ? err.message : t('logs.live_load_error'))
}
} finally {
if (!cancelled) setLoading(false)
const loaded = await loadEntry(logbookId, id)
if (seq !== initSeqRef.current) return
if (loaded) {
applyLoadedEntry(loaded)
} else {
throw new Error(t('logs.live_load_error'))
}
} catch (err: unknown) {
if (seq !== initSeqRef.current) return
console.error('Failed to init live log:', err)
setError(err instanceof Error ? err.message : t('logs.live_load_error'))
} finally {
if (seq === initSeqRef.current) {
setLoading(false)
}
}
}, [logbookId, applyLoadedEntry, t])
void init()
return () => { cancelled = true }
}, [logbookId, refreshEntry, t])
useEffect(() => {
void runInit()
return () => {
initSeqRef.current += 1
}
}, [runInit])
useEffect(() => {
if (!loading && entryId) {
@@ -207,12 +300,12 @@ export default function LiveLogView({
const maybeAutoPosition = async () => {
if (document.visibilityState !== 'visible' || autoPositionBusyRef.current || busy) return
const lastMs = getLastAutoPositionMs(events, date)
const lastMs = getLastAutoPositionMs(eventsRef.current, dateRef.current)
if (lastMs != null && Date.now() - lastMs < AUTO_POSITION_INTERVAL_MS) return
autoPositionBusyRef.current = true
try {
const coords = await getCurrentPosition()
const coords = await getCurrentPosition(8000)
await appendQuickEvent(logbookId, entryId, {
gpsLat: coords.lat,
gpsLng: coords.lng,
@@ -226,12 +319,20 @@ export default function LiveLogView({
}
}
const interval = window.setInterval(() => void maybeAutoPosition(), AUTO_POSITION_CHECK_MS)
return () => window.clearInterval(interval)
}, [entryId, loading, events, date, logbookId, refreshEntry, busy])
let intervalRef: number | undefined
const startTimer = window.setTimeout(() => {
void maybeAutoPosition()
intervalRef = window.setInterval(() => void maybeAutoPosition(), AUTO_POSITION_CHECK_MS)
}, AUTO_POSITION_START_DELAY_MS)
return () => {
window.clearTimeout(startTimer)
if (intervalRef !== undefined) window.clearInterval(intervalRef)
}
}, [entryId, loading, logbookId, refreshEntry, busy])
const runQuickAction = async (
action: () => Promise<void>,
action: () => Promise<boolean | void>,
trackAction?: string,
withUndo = true
) => {
@@ -239,7 +340,8 @@ export default function LiveLogView({
setBusy(true)
setError(null)
try {
await action()
const saved = await action()
if (saved === false) return
await refreshEntry(entryId)
if (withUndo) showUndo()
if (trackAction) {
@@ -296,40 +398,203 @@ export default function LiveLogView({
}, 'moor')
}
const handleFix = () => {
const openFixModal = async () => {
setFixLat('')
setFixLng('')
setFixGpsUnavailable(false)
setFixGpsLoading(true)
setModal('fix')
try {
const coords = await getCurrentPosition()
setFixLat(coords.lat)
setFixLng(coords.lng)
} catch {
setFixGpsUnavailable(true)
} finally {
setFixGpsLoading(false)
}
}
const retryFixGps = async () => {
setFixGpsLoading(true)
setFixGpsUnavailable(false)
try {
const coords = await getCurrentPosition()
setFixLat(coords.lat)
setFixLng(coords.lng)
} catch {
setFixGpsUnavailable(true)
await showAlert(t('logs.live_gps_error'), t('logs.live_fix'))
} finally {
setFixGpsLoading(false)
}
}
const confirmFix = () => {
const coords = normalizeGpsCoordinates(fixLat, fixLng)
if (!coords) {
void showAlert(t('logs.live_fix_invalid'), t('logs.live_fix'))
return
}
setModal('none')
void runQuickAction(async () => {
if (!entryId) return
try {
const coords = await getCurrentPosition()
await appendQuickEvent(logbookId, entryId, {
gpsLat: coords.lat,
gpsLng: coords.lng,
remarks: LIVE_EVENT_CODES.FIX
})
} catch {
await showAlert(t('logs.live_gps_error'), t('logs.live_fix'))
}
if (!entryId) return false
await appendQuickEvent(logbookId, entryId, {
gpsLat: coords.lat,
gpsLng: coords.lng,
remarks: LIVE_EVENT_CODES.FIX
})
}, 'fix')
}
const handleFetchOwmWeather = () => {
if (!entryId || busy || weatherOwmLoading) return
const position = getLastPositionFixWithin(
events,
date,
LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS
)
if (!position) {
const latest = getLatestPositionFix(events, date)
void showAlert(
latest
? t('logs.live_weather_fix_stale')
: t('logs.live_weather_fix_required'),
t('logs.live_weather_owm_btn')
)
return
}
const { lat, lng } = position
const id = entryId
setWeatherOwmLoading(true)
setError(null)
void (async () => {
try {
let data: Record<string, unknown>
try {
data = await fetchOpenWeatherCurrent(
{ lat, lon: lng },
{ analyticsSource: 'live_log' }
)
} catch (err) {
if (err instanceof WeatherApiError && err.code === 'NO_KEY') {
void showAlert(t('settings.no_key'), t('logs.live_weather_owm_btn'))
return
}
console.error('Live log OWM weather failed:', err)
void showAlert(t('settings.weather_error'), t('logs.live_weather_owm_btn'))
return
}
const parsed = parseOwmCurrentWeather(data)
const partials: Partial<LogEventPayload>[] = []
if (parsed.windDirection || parsed.windStrength) {
partials.push({
windDirection: parsed.windDirection,
windStrength: parsed.windStrength,
weatherIcon: parsed.weatherIcon || undefined,
remarks: LIVE_EVENT_CODES.WIND
})
}
if (parsed.windPressure) {
partials.push({
windPressure: parsed.windPressure,
remarks: LIVE_EVENT_CODES.PRESSURE
})
}
if (parsed.tempC) {
partials.push({ remarks: liveTempRemark(parsed.tempC) })
}
if (parsed.precipText) {
partials.push({ remarks: livePrecipRemark(parsed.precipText) })
}
if (partials.length === 0) {
void showAlert(t('settings.weather_error'), t('logs.live_weather_owm_btn'))
return
}
await appendQuickEvents(logbookId, id, partials)
await refreshEntry(id)
showUndo()
} catch (err: unknown) {
console.error('Live log OWM weather save failed:', err)
setError(err instanceof Error ? err.message : t('logs.live_action_error'))
} finally {
setWeatherOwmLoading(false)
}
})()
}
const handleUndo = () => {
if (!entryId || busy) return
const photoId = undoPhotoIdRef.current
setUndoVisible(false)
undoPhotoIdRef.current = null
if (undoTimerRef.current) {
window.clearTimeout(undoTimerRef.current)
undoTimerRef.current = null
}
void runQuickAction(async () => {
if (photoId) {
await deleteEntryPhoto(logbookId, photoId)
}
await removeLastEvent(logbookId, entryId)
}, 'undo', false)
}
const openPhotoModal = () => {
setPhotoCaption('')
setModal('photo')
}
const closePhotoModal = () => {
if (photoSaving) return
setModal('none')
setPhotoCaption('')
}
const handlePhotoCapture = (blob: Blob) => {
if (!entryId || photoSaving) return
const caption = photoCaption.trim()
setPhotoSaving(true)
void (async () => {
try {
const imageDataUrl = await blobToCompressedJpegDataUrl(blob)
const photoId = await saveEntryPhoto({
logbookId,
entryId,
imageDataUrl,
caption,
analyticsContext: 'live_log'
})
await appendQuickEvent(logbookId, entryId, {
remarks: livePhotoRemark(caption)
})
await refreshEntry(entryId)
undoPhotoIdRef.current = photoId
setModal('none')
setPhotoCaption('')
showUndo('photo')
} catch (err: unknown) {
console.error('Live log photo save failed:', err)
void showAlert(
err instanceof Error ? err.message : t('logs.live_photo_error'),
t('logs.live_photo_btn')
)
} finally {
setPhotoSaving(false)
}
})()
}
const confirmSails = () => {
if (selectedSails.length === 0) {
const sailsLabel = joinSailSelection(selectedSails)
if (!sailsLabel) {
setModal('none')
return
}
const sailsLabel = selectedSails.join(' + ')
setModal('none')
setSelectedSails([])
void runQuickAction(async () => {
@@ -468,18 +733,24 @@ export default function LiveLogView({
}
const toggleSailSelection = (sail: string) => {
setSelectedSails((prev) =>
prev.some((s) => s.toLowerCase() === sail.toLowerCase())
? prev.filter((s) => s.toLowerCase() !== sail.toLowerCase())
: [...prev, sail]
)
setSelectedSails((prev) => toggleSailInSelection(prev, sail))
}
const closeModal = () => setModal('none')
if (loading) {
return (
<div className="tab-placeholder">
<Radio className="header-logo spin" size={48} />
<p>{t('logs.live_loading')}</p>
{error && (
<>
<p className="auth-error" style={{ marginTop: 12 }}>{error}</p>
<button type="button" className="btn secondary" style={{ marginTop: 12 }} onClick={() => void runInit()}>
{t('logs.live_retry')}
</button>
</>
)}
</div>
)
}
@@ -568,6 +839,15 @@ export default function LiveLogView({
</button>
{weatherExpanded && (
<div className="live-log-weather-submenu">
<button
type="button"
className="live-log-subaction-btn live-log-subaction-btn-owm"
onClick={handleFetchOwmWeather}
disabled={busy || weatherOwmLoading}
aria-busy={busy || weatherOwmLoading}
>
{weatherOwmLoading ? t('logs.live_weather_owm_loading') : t('logs.live_weather_owm_btn')}
</button>
<button type="button" className="live-log-subaction-btn" onClick={() => openValueModal('wind', lastWindDirectionFromEvents(events))} disabled={busy}>
{t('logs.live_wind_btn')}
</button>
@@ -587,7 +867,7 @@ export default function LiveLogView({
)}
</div>
<button type="button" className="live-log-action-btn" onClick={handleFix} disabled={busy}>
<button type="button" className="live-log-action-btn" onClick={() => void openFixModal()} disabled={busy}>
<MapPin size={18} />
{t('logs.live_fix')}
</button>
@@ -595,6 +875,10 @@ export default function LiveLogView({
<MessageSquare size={18} />
{t('logs.live_comment_btn')}
</button>
<button type="button" className="live-log-action-btn" onClick={openPhotoModal} disabled={busy || photoSaving}>
<Camera size={18} />
{t('logs.live_photo_btn')}
</button>
</aside>
<section className="live-log-stream-panel" aria-label={t('logs.live_stream_label')}>
@@ -620,33 +904,130 @@ export default function LiveLogView({
<>
{undoVisible && events.length > 0 && (
<div className="live-log-undo-bar" role="status">
<span>{t('logs.live_undo_hint')}</span>
<button type="button" className="btn secondary" onClick={handleUndo} disabled={busy}>
<Undo2 size={16} />
{t('logs.live_undo_btn')}
</button>
<div className="live-log-undo-bar-inner">
<span>
{undoHint === 'photo' ? t('logs.live_undo_photo_hint') : t('logs.live_undo_hint')}
</span>
<button type="button" className="btn secondary" onClick={handleUndo} disabled={busy}>
<Undo2 size={16} />
{t('logs.live_undo_btn')}
</button>
</div>
</div>
)}
{modal === 'sails' && (
<div className="live-log-modal-backdrop" onClick={() => setModal('none')}>
<div
className="live-log-modal-backdrop"
onClick={(e) => { if (e.target === e.currentTarget) closeModal() }}
>
<div className="live-log-modal" onClick={(e) => e.stopPropagation()}>
<h3>{t('logs.live_sails_pick')}</h3>
<div className="sails-picker-pills live-log-sail-pills">
{sailOptions.map((sail) => (
<button
key={sail}
type="button"
className={`sail-pill ${selectedSails.some((s) => s.toLowerCase() === sail.toLowerCase()) ? 'active' : ''}`}
onClick={() => toggleSailSelection(sail)}
>
{sail}
</button>
))}
<p className="live-log-modal-hint">{t('logs.live_sails_pick_hint')}</p>
<div
className="sails-picker-pills live-log-sail-pills"
role="group"
aria-label={t('logs.live_sails_pick')}
>
{sailOptions.map((sail) => {
const active = isSailInSelection(selectedSails, sail)
return (
<button
key={sail}
type="button"
className={`sail-pill ${active ? 'active' : ''}`}
aria-pressed={active}
onClick={() => toggleSailSelection(sail)}
>
{sail}
</button>
)
})}
</div>
{selectedSails.length > 0 && (
<p className="live-log-sails-selection" aria-live="polite">
{t('logs.live_sails_selected', { sails: joinSailSelection(selectedSails) })}
</p>
)}
<div className="live-log-modal-actions">
<button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.confirm_no')}</button>
<button type="button" className="btn primary" onClick={confirmSails} disabled={selectedSails.length === 0}>{t('logs.live_sails_confirm')}</button>
<button type="button" className="btn secondary" onClick={closeModal}>{t('logs.confirm_no')}</button>
<button
type="button"
className="btn primary"
onClick={confirmSails}
disabled={selectedSails.length === 0}
>
{selectedSails.length > 0
? t('logs.live_sails_confirm_count', { count: selectedSails.length })
: t('logs.live_sails_confirm')}
</button>
</div>
</div>
</div>
)}
{modal === 'fix' && (
<div
className="live-log-modal-backdrop"
onClick={(e) => { if (e.target === e.currentTarget) closeModal() }}
>
<div className="live-log-modal" onClick={(e) => e.stopPropagation()}>
<h3>{t('logs.live_fix')}</h3>
{fixGpsUnavailable && (
<p className="live-log-modal-hint">{t('logs.live_fix_manual_hint')}</p>
)}
<fieldset className="live-log-fix-coords" disabled={busy}>
<legend className="live-log-fix-label">{t('logs.event_gps')}</legend>
<div className="live-log-fix-coords-row">
<label className="live-log-fix-field">
<span className="live-log-fix-field-label">{t('logs.live_fix_lat_placeholder')}</span>
<input
type="text"
inputMode="decimal"
className="input-text"
placeholder="54.123456"
value={fixLat}
onChange={(e) => setFixLat(e.target.value)}
autoFocus
/>
</label>
<label className="live-log-fix-field">
<span className="live-log-fix-field-label">{t('logs.live_fix_lng_placeholder')}</span>
<input
type="text"
inputMode="decimal"
className="input-text"
placeholder="10.654321"
value={fixLng}
onChange={(e) => setFixLng(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') confirmFix() }}
/>
</label>
</div>
<div className="live-log-fix-gps-row">
<button
type="button"
className="btn secondary live-log-fix-gps-btn"
onClick={() => void retryFixGps()}
title={t('logs.gps_btn')}
disabled={fixGpsLoading}
aria-label={t('logs.gps_btn')}
>
<MapPin size={16} />
<span>{fixGpsLoading ? t('logs.live_fix_gps_loading') : t('logs.gps_btn')}</span>
</button>
</div>
</fieldset>
<div className="live-log-modal-actions">
<button type="button" className="btn secondary" onClick={closeModal}>{t('logs.confirm_no')}</button>
<button
type="button"
className="btn primary"
onClick={confirmFix}
disabled={busy || !normalizeGpsCoordinates(fixLat, fixLng)}
>
{t('logs.live_sails_confirm')}
</button>
</div>
</div>
</div>
@@ -763,6 +1144,15 @@ export default function LiveLogView({
</div>
</div>
)}
<LiveCameraCapture
open={modal === 'photo'}
busy={photoSaving}
caption={photoCaption}
onCaptionChange={setPhotoCaption}
onClose={closePhotoModal}
onCapture={handlePhotoCapture}
/>
</>,
document.body
)}
+4 -2
View File
@@ -149,17 +149,19 @@ export default function LogEntriesList({
}, [logbookId, readOnly, preloadedEntries])
useEffect(() => {
if (viewMode === 'live') return
loadEntries()
}, [loadEntries])
}, [loadEntries, viewMode])
useEffect(() => {
if (viewMode === 'live') return
const prevSelectedEntryId = prevSelectedEntryIdRef.current
prevSelectedEntryIdRef.current = selectedEntryId
if (prevSelectedEntryId !== undefined && prevSelectedEntryId !== null && selectedEntryId === null) {
loadEntries()
}
}, [selectedEntryId, loadEntries])
}, [selectedEntryId, loadEntries, viewMode])
const handleDownloadCsv = async () => {
setExporting(true)
+12 -35
View File
@@ -25,7 +25,7 @@ import type { SignatureValue } from '../types/signatures.js'
import { buildLogEntryPayload, sortLogEventsByTime, normalizeLogEvent, hasUnsavedEventDraft, currentLocalTimeHHMM, isValidTimeHHMM, type LogEventPayload } from '../utils/logEntryPayload.js'
import EventTimeInput24h from './EventTimeInput24h.tsx'
import CourseDialInput from './CourseDialInput.tsx'
import { degreesToCardinal } from '../utils/courseAngle.js'
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
import { hashEntryForSigning } from '../utils/entryCanonicalHash.js'
import { signLogEntry } from '../services/entrySigning.js'
import { getLogbookAccess } from '../services/logbookAccess.js'
@@ -900,7 +900,10 @@ export default function LogEntryEditor({
}
try {
const data = await fetchOpenWeatherCurrent({ q: locationQuery })
const data = await fetchOpenWeatherCurrent(
{ q: locationQuery },
{ analyticsSource: 'entry_editor_gps_lookup' }
)
const coord = data.coord as { lat?: number; lon?: number } | undefined
if (coord?.lat !== undefined && coord?.lon !== undefined) {
setEvGpsLat(Number(coord.lat).toFixed(6))
@@ -955,7 +958,8 @@ export default function LogEntryEditor({
const data = await fetchOpenWeatherCurrent(
hasGps
? { lat: evGpsLat, lon: evGpsLng }
: { q: fallbackLocation }
: { q: fallbackLocation },
{ analyticsSource: 'entry_editor' }
)
const coord = data.coord as { lat?: number; lon?: number } | undefined
@@ -965,38 +969,11 @@ export default function LogEntryEditor({
setEvGpsLng(Number(coord.lon).toFixed(6))
}
const wind = data.wind as { speed?: number; deg?: number } | undefined
const main = data.main as { pressure?: number } | undefined
// Convert wind speed m/s to Beaufort scale
const mps = wind?.speed || 0
let bft = 0
if (mps < 0.3) bft = 0
else if (mps < 1.6) bft = 1
else if (mps < 3.4) bft = 2
else if (mps < 5.5) bft = 3
else if (mps < 8.0) bft = 4
else if (mps < 10.8) bft = 5
else if (mps < 13.9) bft = 6
else if (mps < 17.2) bft = 7
else if (mps < 20.8) bft = 8
else if (mps < 24.5) bft = 9
else if (mps < 28.5) bft = 10
else if (mps < 32.7) bft = 11
else bft = 12
setEvWindStrength(`${bft} Bft (${mps.toFixed(1)} m/s)`)
setEvWindPressure(String(main?.pressure || ''))
// Calculate wind compass direction sector
if (wind?.deg !== undefined) {
setEvWindDirection(degreesToCardinal(wind.deg))
}
if (data.weather && Array.isArray(data.weather) && data.weather[0]) {
const first = data.weather[0] as { icon?: string }
if (first.icon) setEvWeatherIcon(first.icon)
}
const parsed = parseOwmCurrentWeather(data)
setEvWindStrength(parsed.windStrength)
setEvWindPressure(parsed.windPressure)
if (parsed.windDirection) setEvWindDirection(parsed.windDirection)
if (parsed.weatherIcon) setEvWeatherIcon(parsed.weatherIcon)
showAlert(t('settings.weather_success'))
} catch (err) {
+21 -100
View File
@@ -3,9 +3,9 @@ import { useTranslation } from 'react-i18next'
import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js'
import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { decryptJson } from '../services/crypto.js'
import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.js'
import { fileToCompressedJpegDataUrl } from '../utils/imageCompress.js'
import { useLiveQuery } from 'dexie-react-hooks'
import { useDialog } from './ModalDialog.tsx'
import { Camera, Trash2 } from 'lucide-react'
@@ -90,109 +90,30 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
setUploading(true)
setError(null)
const reader = new FileReader()
reader.onload = (event) => {
const img = new Image()
img.onload = async () => {
try {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Could not get canvas context')
let width = img.width
let height = img.height
const MAX_WIDTH = 1280
const MAX_HEIGHT = 720
// Calculate resizing conserving aspect ratio
if (width > MAX_WIDTH || height > MAX_HEIGHT) {
const ratio = Math.min(MAX_WIDTH / width, MAX_HEIGHT / height)
width = Math.round(width * ratio)
height = Math.round(height * ratio)
}
canvas.width = width
canvas.height = height
ctx.drawImage(img, 0, 0, width, height)
// Compress to JPEG, 70% quality
const compressedBase64 = canvas.toDataURL('image/jpeg', 0.7)
// Encrypt
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const photoId = window.crypto.randomUUID()
const photoPayload = {
image: compressedBase64,
caption: caption.trim()
}
const encrypted = await encryptJson(photoPayload, masterKey)
const now = new Date().toISOString()
// Store locally
await db.photos.put({
payloadId: photoId,
entryId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
caption: '', // stored encrypted inside payload
updatedAt: now
})
// Queue for background sync
await db.syncQueue.put({
action: 'create',
type: 'photo',
payloadId: photoId,
logbookId,
data: JSON.stringify({
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
entryId
}),
updatedAt: now
})
setCaption('')
if (fileInputRef.current) fileInputRef.current.value = ''
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: 'logbook' })
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
} catch (err: any) {
console.error('Failed to process image:', err)
setError(err.message || 'Failed to process image')
} finally {
setUploading(false)
}
}
img.src = event.target?.result as string
try {
const compressedBase64 = await fileToCompressedJpegDataUrl(file)
await saveEntryPhoto({
logbookId,
entryId,
imageDataUrl: compressedBase64,
caption: caption.trim(),
analyticsContext: 'logbook'
})
setCaption('')
if (fileInputRef.current) fileInputRef.current.value = ''
} catch (err: unknown) {
console.error('Failed to process image:', err)
setError(err instanceof Error ? err.message : 'Failed to process image')
} finally {
setUploading(false)
}
reader.readAsDataURL(file)
}
const handleDelete = async (photoId: string) => {
if (await showConfirm(t('logs.photo_delete_confirm'), t('logs.photos_title'), t('logs.confirm_yes'), t('logs.confirm_no'))) {
try {
const now = new Date().toISOString()
await db.photos.delete(photoId)
await db.syncQueue.put({
action: 'delete',
type: 'photo',
payloadId: photoId,
logbookId,
data: '',
updatedAt: now
})
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
} catch (err: any) {
await deleteEntryPhoto(logbookId, photoId)
} catch (err: unknown) {
console.error('Failed to delete photo:', err)
}
}
+43 -34
View File
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
import { Settings as SettingsIcon, Check, Users, Trash2, Copy, Link as LinkIcon } from 'lucide-react'
import { ensureLogbookKey } from '../services/logbookKeys.js'
import LogbookBackupPanel from './LogbookBackupPanel.tsx'
import LinkQrCode from './LinkQrCode.tsx'
import { useDialog } from './ModalDialog.tsx'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { apiFetch } from '../services/api.js'
@@ -314,23 +315,27 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
</div>
{shareEnabled && shareLink && (
<div className="input-group mb-4 copy-link-row">
<input
type="text"
readOnly
value={shareLink}
className="input-text font-mono text-xs"
style={{ flex: 1, padding: '10px' }}
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<button
type="button"
className="btn secondary"
onClick={handleCopyShareLink}
style={{ width: 'auto', padding: '10px' }}
>
{shareCopied ? <Check size={16} /> : <Copy size={16} />}
</button>
<div className="link-with-qr mb-4">
<div className="input-group copy-link-row">
<input
type="text"
readOnly
value={shareLink}
className="input-text font-mono text-xs"
style={{ flex: 1, padding: '10px' }}
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<button
type="button"
className="btn secondary"
onClick={handleCopyShareLink}
style={{ width: 'auto', padding: '10px' }}
title={t('settings.share_copy_btn')}
>
{shareCopied ? <Check size={16} /> : <Copy size={16} />}
</button>
</div>
<LinkQrCode value={shareLink} />
</div>
)}
</div>
@@ -367,23 +372,27 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
</div>
{inviteLink && (
<div className="input-group mb-6 copy-link-row">
<input
type="text"
readOnly
value={inviteLink}
className="input-text font-mono text-xs"
style={{ flex: 1, padding: '10px' }}
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<button
type="button"
className="btn secondary"
onClick={handleCopyInvite}
style={{ width: 'auto', padding: '10px' }}
>
{inviteCopied ? <Check size={16} /> : <Copy size={16} />}
</button>
<div className="link-with-qr mb-6">
<div className="input-group copy-link-row">
<input
type="text"
readOnly
value={inviteLink}
className="input-text font-mono text-xs"
style={{ flex: 1, padding: '10px' }}
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<button
type="button"
className="btn secondary"
onClick={handleCopyInvite}
style={{ width: 'auto', padding: '10px' }}
title={t('settings.share_copy_btn')}
>
{inviteCopied ? <Check size={16} /> : <Copy size={16} />}
</button>
</div>
<LinkQrCode value={inviteLink} />
</div>
)}
+35 -1
View File
@@ -68,7 +68,12 @@
"enter_pin_placeholder": "Indtast din pinkode...",
"decrypt_with_pin": "Afkodning",
"use_recovery_instead": "Brug genoprettelsesnøgler i stedet",
"error_incorrect_pin": "Forkert PIN-kode. Dekryptering mislykkedes."
"error_incorrect_pin": "Forkert PIN-kode. Dekryptering mislykkedes.",
"error_invalid_host": "Passkeys virker ikke via 127.0.0.1. Åbn appen via localhost.",
"use_localhost_link": "Skift til localhost",
"error_passkey_cancelled": "Passkey-login blev annulleret eller udløb. Prøv igen.",
"error_invalid_rp_id": "Passkey-domæne matcher ikke (RP ID). Brug http://localhost:5173 med RP_ID=localhost i .env til lokal udvikling.",
"error_session_incomplete": "Login ufuldstændig. Log ind med passkey igen."
},
"pwa": {
"title": "Installer app",
@@ -202,6 +207,7 @@
"live_mode": "Live",
"live_title": "Live-journal",
"live_loading": "Live-journal indlæses...",
"live_retry": "Prøv igen",
"live_load_error": "Live-journal kunne ikke indlæses.",
"live_action_error": "Indtastning kunne ikke gemmes.",
"live_open_editor": "Fuld editor",
@@ -215,16 +221,42 @@
"live_moor": "Anløb",
"live_sails_btn": "Sejl",
"live_sails_pick": "Vælg sejl",
"live_sails_pick_hint": "Tryk på flere sejl (tryk igen for at fravælge), og indtast derefter.",
"live_sails_selected": "Valgt: {{sails}}",
"live_sails_confirm": "Indtast",
"live_sails_confirm_count": "Indtast ({{count}})",
"live_sails": "Sejl: {{sails}}",
"live_fix": "Fix",
"live_fix_coords": "Fix {{lat}}, {{lng}}",
"live_fix_manual_hint": "GPS ikke tilgængelig. Indtast bredde- og længdegrad manuelt, eller prøv igen med GPS-knappen.",
"live_fix_gps_loading": "Henter GPS-position…",
"live_fix_invalid": "Indtast gyldige koordinater (bredde 90…90, længde 180…180).",
"live_fix_lat_placeholder": "Bredde (Lat)",
"live_fix_lng_placeholder": "Længde (Lng)",
"live_photo_btn": "Foto (kamera)",
"live_photo_capture_btn": "Tag billede",
"live_photo_save_btn": "Gem",
"live_photo_retake_btn": "Tag igen",
"live_photo_capture_failed": "Optagelse mislykkedes. Prøv igen.",
"live_photo_open_camera_btn": "Åbn kamera",
"live_photo_native_hint": "Tag et foto med enhedens kamera og gem det her bagefter.",
"live_photo_camera_starting": "Starter kamera…",
"live_photo_camera_denied": "Kameraadgang nægtet eller utilgængelig.",
"live_photo_camera_unavailable": "Kamera understøttes ikke i denne browser.",
"live_photo_error": "Foto kunne ikke gemmes.",
"live_photo_entry": "Foto: {{caption}}",
"live_photo_entry_plain": "Foto taget",
"live_undo_photo_hint": "Foto gemt",
"live_comment_btn": "Kommentar",
"live_comment_placeholder": "Indtast tekst…",
"live_comment_confirm": "Indtast",
"live_gps_error": "GPS-position kunne ikke bestemmes.",
"live_event_generic": "Hændelse",
"live_weather_btn": "Vejr",
"live_weather_owm_btn": "Hent OpenWeatherMap-vejr",
"live_weather_owm_loading": "Henter vejr…",
"live_weather_fix_required": "Log først en GPS-fix (Fix-knap) for at hente OpenWeatherMap-vejr. Positionen må højst være 6 timer gammel.",
"live_weather_fix_stale": "Den seneste GPS-fix er ældre end 6 timer. Log en ny fix, før du henter vejr.",
"live_wind_btn": "Vind",
"live_temp_btn": "T °C",
"live_pressure_btn": "Lufttryk",
@@ -606,6 +638,8 @@
"share_enable": "Aktivér offentligt link",
"share_copied": "Link kopieret!",
"share_copy_btn": "Kopier link",
"link_qr_hint": "Scan QR-koden med din telefon",
"link_qr_alt": "QR-kode til linket",
"danger_zone_title": "Farezone",
"danger_zone_desc": "Når du sletter din konto, slettes alle dine Passkey'er, logbøger, skibsdata, besætningsprofiler, rejseindlæg og E2E-nøgler uigenkaldeligt. Denne handling kan ikke fortrydes.",
"delete_account_btn": "Slet konto uigenkaldeligt",
+36 -2
View File
@@ -68,7 +68,12 @@
"enter_pin_placeholder": "Gib deine PIN ein...",
"decrypt_with_pin": "Entschlüsseln",
"use_recovery_instead": "Stattdessen Wiederherstellungsschlüssel verwenden",
"error_incorrect_pin": "Falsche PIN. Entschlüsselung fehlgeschlagen."
"error_incorrect_pin": "Falsche PIN. Entschlüsselung fehlgeschlagen.",
"error_invalid_host": "Passkeys funktionieren nicht über 127.0.0.1. Bitte die App über localhost öffnen.",
"use_localhost_link": "Zu localhost wechseln",
"error_passkey_cancelled": "Passkey-Anmeldung abgebrochen oder abgelaufen. Bitte erneut versuchen.",
"error_invalid_rp_id": "Passkey-Domain passt nicht (RP ID). Lokal nur http://localhost:5173 mit RP_ID=localhost in .env verwenden.",
"error_session_incomplete": "Anmeldung unvollständig. Bitte erneut mit Passkey anmelden."
},
"pwa": {
"title": "App installieren",
@@ -202,6 +207,7 @@
"live_mode": "Live",
"live_title": "Live-Journal",
"live_loading": "Live-Journal wird geladen...",
"live_retry": "Erneut versuchen",
"live_load_error": "Live-Journal konnte nicht geladen werden.",
"live_action_error": "Eintrag konnte nicht gespeichert werden.",
"live_open_editor": "Vollständiger Editor",
@@ -214,17 +220,43 @@
"live_cast_off": "Ablegen",
"live_moor": "Anlegen",
"live_sails_btn": "Segel",
"live_sails_pick": "Segel wählen",
"live_sails_pick": "Segel auswählen",
"live_sails_pick_hint": "Mehrere Segel antippen (erneut antippen zum Abwählen), dann Eintragen.",
"live_sails_selected": "Auswahl: {{sails}}",
"live_sails_confirm": "Eintragen",
"live_sails_confirm_count": "Eintragen ({{count}})",
"live_sails": "Segel: {{sails}}",
"live_fix": "Fix",
"live_fix_coords": "Fix {{lat}}, {{lng}}",
"live_fix_manual_hint": "GPS nicht verfügbar. Breiten- und Längengrad manuell eingeben oder erneut per GPS-Knopf versuchen.",
"live_fix_gps_loading": "GPS-Position wird ermittelt…",
"live_fix_invalid": "Bitte gültige Koordinaten eingeben (Breite 90…90, Länge 180…180).",
"live_fix_lat_placeholder": "Breite (Lat)",
"live_fix_lng_placeholder": "Länge (Lng)",
"live_photo_btn": "Foto (Kamera)",
"live_photo_capture_btn": "Aufnehmen",
"live_photo_save_btn": "Speichern",
"live_photo_retake_btn": "Neu aufnehmen",
"live_photo_capture_failed": "Aufnahme fehlgeschlagen. Bitte erneut versuchen.",
"live_photo_open_camera_btn": "Kamera öffnen",
"live_photo_native_hint": "Foto mit der Gerätekamera aufnehmen und anschließend hier speichern.",
"live_photo_camera_starting": "Kamera wird gestartet…",
"live_photo_camera_denied": "Kamerazugriff verweigert oder nicht verfügbar.",
"live_photo_camera_unavailable": "Kamera wird von diesem Browser nicht unterstützt.",
"live_photo_error": "Foto konnte nicht gespeichert werden.",
"live_photo_entry": "Foto: {{caption}}",
"live_photo_entry_plain": "Foto aufgenommen",
"live_undo_photo_hint": "Foto gespeichert",
"live_comment_btn": "Kommentar",
"live_comment_placeholder": "Freitext eingeben…",
"live_comment_confirm": "Eintragen",
"live_gps_error": "GPS-Position konnte nicht ermittelt werden.",
"live_event_generic": "Ereignis",
"live_weather_btn": "Wetter",
"live_weather_owm_btn": "OpenWeatherMap Wetter abrufen",
"live_weather_owm_loading": "Wetter wird geladen…",
"live_weather_fix_required": "Für Wetter von OpenWeatherMap zuerst einen GPS-Fix eintragen (Schaltfläche „Fix“). Die Position darf höchstens 6 Stunden alt sein.",
"live_weather_fix_stale": "Der letzte GPS-Fix ist älter als 6 Stunden. Bitte erneut einen Fix loggen, bevor du Wetter abrufst.",
"live_wind_btn": "Wind",
"live_temp_btn": "T °C",
"live_pressure_btn": "Luftdruck",
@@ -606,6 +638,8 @@
"share_enable": "Öffentlichen Link aktivieren",
"share_copied": "Link kopiert!",
"share_copy_btn": "Link kopieren",
"link_qr_hint": "QR-Code zum Scannen mit dem Smartphone",
"link_qr_alt": "QR-Code für den Link",
"danger_zone_title": "Gefahrenzone",
"danger_zone_desc": "Durch das Löschen deines Kontos werden alle deine Passkeys, Logbücher, Schiffsdaten, Crew-Profile, Reiseeinträge und E2E-Schlüssel unwiderruflich gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
"delete_account_btn": "Konto unwiderruflich löschen",
+35 -1
View File
@@ -68,7 +68,12 @@
"enter_pin_placeholder": "Enter your PIN...",
"decrypt_with_pin": "Decrypt",
"use_recovery_instead": "Use recovery phrase instead",
"error_incorrect_pin": "Incorrect PIN. Decryption failed."
"error_incorrect_pin": "Incorrect PIN. Decryption failed.",
"error_invalid_host": "Passkeys do not work on 127.0.0.1. Please open the app via localhost.",
"use_localhost_link": "Switch to localhost",
"error_passkey_cancelled": "Passkey sign-in was cancelled or timed out. Please try again.",
"error_invalid_rp_id": "Passkey domain mismatch (RP ID). For local dev use http://localhost:5173 with RP_ID=localhost in .env.",
"error_session_incomplete": "Sign-in incomplete. Please sign in with your passkey again."
},
"pwa": {
"title": "Install app",
@@ -202,6 +207,7 @@
"live_mode": "Live",
"live_title": "Live Journal",
"live_loading": "Loading live journal...",
"live_retry": "Try again",
"live_load_error": "Could not load live journal.",
"live_action_error": "Could not save entry.",
"live_open_editor": "Full editor",
@@ -215,16 +221,42 @@
"live_moor": "Moor",
"live_sails_btn": "Sails",
"live_sails_pick": "Select sails",
"live_sails_pick_hint": "Tap multiple sails (tap again to deselect), then log.",
"live_sails_selected": "Selected: {{sails}}",
"live_sails_confirm": "Log entry",
"live_sails_confirm_count": "Log entry ({{count}})",
"live_sails": "Sails: {{sails}}",
"live_fix": "Fix",
"live_fix_coords": "Fix {{lat}}, {{lng}}",
"live_fix_manual_hint": "GPS unavailable. Enter latitude and longitude manually, or try again with the GPS button.",
"live_fix_gps_loading": "Getting GPS position…",
"live_fix_invalid": "Please enter valid coordinates (latitude 90…90, longitude 180…180).",
"live_fix_lat_placeholder": "Latitude (Lat)",
"live_fix_lng_placeholder": "Longitude (Lng)",
"live_photo_btn": "Photo (camera)",
"live_photo_capture_btn": "Capture",
"live_photo_save_btn": "Save",
"live_photo_retake_btn": "Retake",
"live_photo_capture_failed": "Capture failed. Please try again.",
"live_photo_open_camera_btn": "Open camera",
"live_photo_native_hint": "Take a photo with your device camera, then save it here.",
"live_photo_camera_starting": "Starting camera…",
"live_photo_camera_denied": "Camera access denied or unavailable.",
"live_photo_camera_unavailable": "Camera is not supported in this browser.",
"live_photo_error": "Could not save photo.",
"live_photo_entry": "Photo: {{caption}}",
"live_photo_entry_plain": "Photo captured",
"live_undo_photo_hint": "Photo saved",
"live_comment_btn": "Comment",
"live_comment_placeholder": "Enter text…",
"live_comment_confirm": "Log entry",
"live_gps_error": "Could not determine GPS position.",
"live_event_generic": "Event",
"live_weather_btn": "Weather",
"live_weather_owm_btn": "Fetch OpenWeatherMap weather",
"live_weather_owm_loading": "Loading weather…",
"live_weather_fix_required": "Log a GPS fix first (Fix button) to fetch OpenWeatherMap weather. The position must be at most 6 hours old.",
"live_weather_fix_stale": "The last GPS fix is older than 6 hours. Log a new fix before fetching weather.",
"live_wind_btn": "Wind",
"live_temp_btn": "Temp °C",
"live_pressure_btn": "Pressure",
@@ -606,6 +638,8 @@
"share_enable": "Enable Public Link",
"share_copied": "Link copied!",
"share_copy_btn": "Copy Link",
"link_qr_hint": "Scan this QR code with your phone",
"link_qr_alt": "QR code for the link",
"danger_zone_title": "Danger Zone",
"danger_zone_desc": "Deleting your account will permanently delete all your passkeys, logbooks, vessel data, crew profiles, travel logs, and E2E keys. This action cannot be undone.",
"delete_account_btn": "Permanently Delete Account",
+35 -1
View File
@@ -68,7 +68,12 @@
"enter_pin_placeholder": "Tast inn PIN-koden din...",
"decrypt_with_pin": "Dekryptere",
"use_recovery_instead": "Bruk gjenopprettingsnøkler i stedet",
"error_incorrect_pin": "Feil PIN-kode. Dekryptering mislyktes."
"error_incorrect_pin": "Feil PIN-kode. Dekryptering mislyktes.",
"error_invalid_host": "Passkeys fungerer ikke via 127.0.0.1. Åpne appen via localhost.",
"use_localhost_link": "Bytt til localhost",
"error_passkey_cancelled": "Passkey-innlogging ble avbrutt eller utløp. Prøv igjen.",
"error_invalid_rp_id": "Passkey-domene stemmer ikke (RP ID). Bruk http://localhost:5173 med RP_ID=localhost i .env for lokal utvikling.",
"error_session_incomplete": "Innlogging ufullstendig. Logg inn med passkey igjen."
},
"pwa": {
"title": "Installer app",
@@ -202,6 +207,7 @@
"live_mode": "Live",
"live_title": "Live-journal",
"live_loading": "Live-journal lastes inn...",
"live_retry": "Prøv igjen",
"live_load_error": "Live-journal kunne ikke lastes inn.",
"live_action_error": "Oppføringen kunne ikke lagres.",
"live_open_editor": "Full editor",
@@ -215,16 +221,42 @@
"live_moor": "Anløp",
"live_sails_btn": "Seil",
"live_sails_pick": "Velg seil",
"live_sails_pick_hint": "Trykk flere seil (trykk igjen for å fjerne), deretter loggfør.",
"live_sails_selected": "Valgt: {{sails}}",
"live_sails_confirm": "Loggfør",
"live_sails_confirm_count": "Loggfør ({{count}})",
"live_sails": "Seil: {{sails}}",
"live_fix": "Fix",
"live_fix_coords": "Fix {{lat}}, {{lng}}",
"live_fix_manual_hint": "GPS ikke tilgjengelig. Skriv inn bredde- og lengdegrad manuelt, eller prøv igjen med GPS-knappen.",
"live_fix_gps_loading": "Henter GPS-posisjon…",
"live_fix_invalid": "Skriv inn gyldige koordinater (bredde 90…90, lengde 180…180).",
"live_fix_lat_placeholder": "Bredde (Lat)",
"live_fix_lng_placeholder": "Lengde (Lng)",
"live_photo_btn": "Foto (kamera)",
"live_photo_capture_btn": "Ta bilde",
"live_photo_save_btn": "Lagre",
"live_photo_retake_btn": "Ta på nytt",
"live_photo_capture_failed": "Opptak mislyktes. Prøv igjen.",
"live_photo_open_camera_btn": "Åpne kamera",
"live_photo_native_hint": "Ta et bilde med enhetskameraet og lagre det her etterpå.",
"live_photo_camera_starting": "Starter kamera…",
"live_photo_camera_denied": "Kameratilgang nektet eller utilgjengelig.",
"live_photo_camera_unavailable": "Kamera støttes ikke i denne nettleseren.",
"live_photo_error": "Kunne ikke lagre foto.",
"live_photo_entry": "Foto: {{caption}}",
"live_photo_entry_plain": "Foto tatt",
"live_undo_photo_hint": "Foto lagret",
"live_comment_btn": "Kommentar",
"live_comment_placeholder": "Skriv inn tekst…",
"live_comment_confirm": "Loggfør",
"live_gps_error": "GPS-posisjon kunne ikke bestemmes.",
"live_event_generic": "Hendelse",
"live_weather_btn": "Vær",
"live_weather_owm_btn": "Hent OpenWeatherMap-vær",
"live_weather_owm_loading": "Henter vær…",
"live_weather_fix_required": "Logg først en GPS-fix (Fix-knapp) for å hente OpenWeatherMap-vær. Posisjonen må være maks 6 timer gammel.",
"live_weather_fix_stale": "Siste GPS-fix er eldre enn 6 timer. Logg en ny fix før du henter vær.",
"live_wind_btn": "Vind",
"live_temp_btn": "T °C",
"live_pressure_btn": "Lufttrykk",
@@ -606,6 +638,8 @@
"share_enable": "Aktiver offentlig lenke",
"share_copied": "Linken er kopiert!",
"share_copy_btn": "Kopier lenke",
"link_qr_hint": "Skann QR-koden med telefonen",
"link_qr_alt": "QR-kode for lenken",
"danger_zone_title": "Faresone",
"danger_zone_desc": "Hvis du sletter kontoen din, slettes alle dine Passkeys, loggbøker, skipsdata, mannskapsprofiler, reiseoppføringer og E2E-nøkler ugjenkallelig. Denne handlingen kan ikke angres.",
"delete_account_btn": "Slett konto ugjenkallelig",
+35 -1
View File
@@ -68,7 +68,12 @@
"enter_pin_placeholder": "Ange din PIN-kod...",
"decrypt_with_pin": "Dekryptera",
"use_recovery_instead": "Använd återställningsnycklar istället",
"error_incorrect_pin": "Felaktig PIN-kod. Dekryptering misslyckades."
"error_incorrect_pin": "Felaktig PIN-kod. Dekryptering misslyckades.",
"error_invalid_host": "Passkeys fungerar inte via 127.0.0.1. Öppna appen via localhost.",
"use_localhost_link": "Byt till localhost",
"error_passkey_cancelled": "Passkey-inloggning avbröts eller gick ut. Försök igen.",
"error_invalid_rp_id": "Passkey-domänen matchar inte (RP ID). Använd http://localhost:5173 med RP_ID=localhost i .env för lokal utveckling.",
"error_session_incomplete": "Inloggning ofullständig. Logga in med passkey igen."
},
"pwa": {
"title": "Installera app",
@@ -202,6 +207,7 @@
"live_mode": "Live",
"live_title": "Live-journal",
"live_loading": "Live-journal laddas...",
"live_retry": "Försök igen",
"live_load_error": "Live-journal kunde inte laddas.",
"live_action_error": "Posten kunde inte sparas.",
"live_open_editor": "Fullständig editor",
@@ -215,16 +221,42 @@
"live_moor": "Anlöp",
"live_sails_btn": "Segel",
"live_sails_pick": "Välj segel",
"live_sails_pick_hint": "Tryck på flera segel (tryck igen för att avmarkera), logga sedan.",
"live_sails_selected": "Valt: {{sails}}",
"live_sails_confirm": "Logga",
"live_sails_confirm_count": "Logga ({{count}})",
"live_sails": "Segel: {{sails}}",
"live_fix": "Fix",
"live_fix_coords": "Fix {{lat}}, {{lng}}",
"live_fix_manual_hint": "GPS ej tillgänglig. Ange latitud och longitud manuellt, eller försök igen med GPS-knappen.",
"live_fix_gps_loading": "Hämtar GPS-position…",
"live_fix_invalid": "Ange giltiga koordinater (latitud 90…90, longitud 180…180).",
"live_fix_lat_placeholder": "Latitud (Lat)",
"live_fix_lng_placeholder": "Longitud (Lng)",
"live_photo_btn": "Foto (kamera)",
"live_photo_capture_btn": "Ta foto",
"live_photo_save_btn": "Spara",
"live_photo_retake_btn": "Ta om",
"live_photo_capture_failed": "Bildtagning misslyckades. Försök igen.",
"live_photo_open_camera_btn": "Öppna kamera",
"live_photo_native_hint": "Ta ett foto med enhetens kamera och spara det här efteråt.",
"live_photo_camera_starting": "Startar kamera…",
"live_photo_camera_denied": "Kameraåtkomst nekad eller ej tillgänglig.",
"live_photo_camera_unavailable": "Kameran stöds inte i den här webbläsaren.",
"live_photo_error": "Foto kunde inte sparas.",
"live_photo_entry": "Foto: {{caption}}",
"live_photo_entry_plain": "Foto taget",
"live_undo_photo_hint": "Foto sparat",
"live_comment_btn": "Kommentar",
"live_comment_placeholder": "Ange text…",
"live_comment_confirm": "Logga",
"live_gps_error": "GPS-position kunde inte bestämmas.",
"live_event_generic": "Händelse",
"live_weather_btn": "Väder",
"live_weather_owm_btn": "Hämta OpenWeatherMap-väder",
"live_weather_owm_loading": "Hämtar väder…",
"live_weather_fix_required": "Logga först en GPS-fix (Fix-knappen) för att hämta OpenWeatherMap-väder. Positionen får högst vara 6 timmar gammal.",
"live_weather_fix_stale": "Senaste GPS-fixen är äldre än 6 timmar. Logga en ny fix innan du hämtar väder.",
"live_wind_btn": "Vind",
"live_temp_btn": "T °C",
"live_pressure_btn": "Lufttryck",
@@ -606,6 +638,8 @@
"share_enable": "Aktivera offentlig länk",
"share_copied": "Länk kopierad!",
"share_copy_btn": "Kopiera länk",
"link_qr_hint": "Skanna QR-koden med mobilen",
"link_qr_alt": "QR-kod för länken",
"danger_zone_title": "Farlig zon",
"danger_zone_desc": "Om du raderar ditt konto raderas oåterkalleligen alla dina Passkey, loggböcker, fartygsdata, besättningsprofiler, reseanteckningar och E2E-nycklar. Denna åtgärd kan inte ångras.",
"delete_account_btn": "Ta bort konto oåterkalleligt",
+5
View File
@@ -12,6 +12,7 @@ import {
markReloadAttempt,
reconcileVersionOnStartup
} from './services/pwaStartup.ts'
import { redirectToPasskeyCompatibleHostIfNeeded } from './utils/passkeyHost.ts'
/** Stale PWA precache on localhost can shadow Vite dev modules. */
async function clearDevServiceWorkerCaches(): Promise<void> {
@@ -40,6 +41,10 @@ function renderBootstrapError(message: string): void {
}
async function bootstrap(): Promise<void> {
if (redirectToPasskeyCompatibleHostIfNeeded()) {
return
}
applyAppearanceToDocument()
installStaleAssetRecovery()
await clearDevServiceWorkerCaches()
+6 -1
View File
@@ -39,9 +39,14 @@ export const PlausibleEvents = {
NMEA_IMPORTED: 'NMEA Imported',
NMEA_UPLOADED: 'NMEA Uploaded',
LIVE_LOG_OPENED: 'Live Log Opened',
LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged'
LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged',
LIVE_LOG_PHOTO_UPLOADED: 'Live Log Photo Uploaded',
OWM_WEATHER_FETCHED: 'OWM Weather Fetched'
} as const
/** Where a successful OpenWeatherMap API call originated (no coordinates or place names). */
export type OwmAnalyticsSource = 'live_log' | 'entry_editor' | 'entry_editor_gps_lookup'
export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents]
export type PlausibleEventProps = Record<string, string | number | boolean>
+6 -1
View File
@@ -12,6 +12,7 @@ import { clearLogbookKeysCache } from './logbookKeys.js'
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
import { db } from './db.js'
import { apiFetch, apiJson } from './api.js'
import { isWebAuthnUserAbortError } from '../utils/passkeyHost.js'
const API_BASE = '/api/auth'
@@ -361,7 +362,11 @@ export async function loginUser(username?: string): Promise<LoginResult> {
const prfRequested = !!options.extensions?.prf
try {
credentialResponse = await startAuthentication({ optionsJSON: options })
} catch (err: any) {
} catch (err: unknown) {
// User cancelled or timed out — never open a second platform prompt.
if (isWebAuthnUserAbortError(err)) {
throw err
}
if (prfRequested) {
console.warn('Passkey authentication with PRF extension failed, retrying without PRF:', err)
if (options.extensions) {
+4
View File
@@ -214,6 +214,10 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
if (response.ok) {
const serverLb = await response.json()
if (serverLb.id !== localId) {
await saveLogbookKey(serverLb.id, logbookKey)
await db.logbookKeys.delete(localId)
}
await db.logbooks.put({
id: serverLb.id,
encryptedTitle: serverLb.encryptedTitle,
+92
View File
@@ -0,0 +1,92 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { encryptJson } from './crypto.js'
import { syncLogbook } from './sync.js'
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
async function getEncryptionKey(logbookId: string): Promise<ArrayBuffer> {
const key = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!key) throw new Error('Encryption key not found. Please log in.')
return key
}
export async function saveEntryPhoto(options: {
logbookId: string
entryId: string
imageDataUrl: string
caption?: string
analyticsContext?: string
}): Promise<string> {
const { logbookId, entryId, imageDataUrl, caption = '', analyticsContext = 'logbook' } = options
const masterKey = await getEncryptionKey(logbookId)
const photoId = window.crypto.randomUUID()
const photoPayload = {
image: imageDataUrl,
caption: caption.trim()
}
const encrypted = await encryptJson(photoPayload, masterKey)
const now = new Date().toISOString()
await db.photos.put({
payloadId: photoId,
entryId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
caption: '',
updatedAt: now
})
await db.syncQueue.put({
action: 'create',
type: 'photo',
payloadId: photoId,
logbookId,
data: JSON.stringify({
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
entryId
}),
updatedAt: now
})
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: analyticsContext })
if (analyticsContext === 'live_log') {
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_PHOTO_UPLOADED)
}
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
return photoId
}
export async function deleteEntryPhoto(logbookId: string, photoId: string): Promise<void> {
const now = new Date().toISOString()
await db.photos.delete(photoId)
await db.syncQueue.put({
action: 'delete',
type: 'photo',
payloadId: photoId,
logbookId,
data: '',
updatedAt: now
})
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
}
/** Deletes the newest photo for an entry; returns its id or null. */
export async function removeLastPhotoForEntry(
logbookId: string,
entryId: string
): Promise<string | null> {
const photos = await db.photos.where({ entryId }).toArray()
if (photos.length === 0) return null
photos.sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
)
const lastId = photos[0].payloadId
await deleteEntryPhoto(logbookId, lastId)
return lastId
}
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it, vi } from 'vitest'
import { tryDecryptEntryPayload } from './quickEventLog.js'
vi.mock('./crypto.js', () => ({
decryptJson: vi.fn(async (_c: string, _i: string, _t: string) => {
throw new Error('decrypt failed')
}),
encryptJson: vi.fn()
}))
describe('tryDecryptEntryPayload', () => {
it('returns null when decryption fails', async () => {
const result = await tryDecryptEntryPayload(
{ encryptedData: 'x', iv: 'y', tag: 'z' },
new ArrayBuffer(32)
)
expect(result).toBeNull()
})
})
+76 -9
View File
@@ -1,6 +1,6 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { ensureLogbookKey, getLogbookKey } from './logbookKeys.js'
import { decryptJson, encryptJson } from './crypto.js'
import { syncLogbook } from './sync.js'
import {
@@ -24,12 +24,36 @@ export interface LoadedEntry {
data: Record<string, unknown>
}
type EncryptedRecord = {
encryptedData: string
iv: string
tag: string
}
async function getMasterKey(logbookId: string): Promise<ArrayBuffer> {
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
return masterKey
}
/** Decrypt one record; skip corrupt or legacy entries instead of aborting the whole scan. */
export async function tryDecryptEntryPayload(
record: EncryptedRecord,
key: ArrayBuffer
): Promise<Record<string, unknown> | null> {
try {
return await decryptJson(record.encryptedData, record.iv, record.tag, key)
} catch {
return null
}
}
function sortEntriesNewestFirst<T extends { updatedAt: string }>(entries: T[]): T[] {
return [...entries].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
)
}
function tankLevelsFromData(data: Record<string, unknown>) {
const fw = (data.freshwater as Record<string, number> | undefined) ?? {
morning: 0, refilled: 0, evening: 0, consumption: 0
@@ -110,7 +134,7 @@ export async function loadEntry(logbookId: string, entryId: string): Promise<Loa
const masterKey = await getMasterKey(logbookId)
const record = await db.entries.get(entryId)
if (!record) return null
const data = await decryptJson(record.encryptedData, record.iv, record.tag, masterKey)
const data = await tryDecryptEntryPayload(record, masterKey)
if (!data) return null
return { payloadId: record.payloadId, updatedAt: record.updatedAt, data }
}
@@ -118,10 +142,10 @@ export async function loadEntry(logbookId: string, entryId: string): Promise<Loa
export async function findTodayEntryId(logbookId: string): Promise<string | null> {
const todayStr = new Date().toISOString().substring(0, 10)
const masterKey = await getMasterKey(logbookId)
const local = await db.entries.where({ logbookId }).toArray()
const local = sortEntriesNewestFirst(await db.entries.where({ logbookId }).toArray())
for (const entry of local) {
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
const decrypted = await tryDecryptEntryPayload(entry, masterKey)
if (decrypted && String(decrypted.date) === todayStr) {
return entry.payloadId
}
@@ -134,9 +158,13 @@ export async function createTodayEntry(logbookId: string): Promise<string> {
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)
if (localEntries.length > 0) {
for (const entry of localEntries) {
const decrypted = await tryDecryptEntryPayload(entry, masterKey)
if (decrypted) {
decryptedEntries.push(decrypted as LogEntryTankSource & TravelDaySortable)
}
}
}
decryptedEntries.sort(compareTravelDaysChronological)
@@ -185,9 +213,19 @@ export async function createTodayEntry(logbookId: string): Promise<string> {
}
export async function findOrCreateTodayEntry(logbookId: string): Promise<string> {
const existing = await findTodayEntryId(logbookId)
const id = logbookId.trim()
if (!id) throw new Error('Logbook id required')
await ensureLogbookKey(id)
const entryCount = await db.entries.where({ logbookId: id }).count()
if (entryCount === 0) {
return createTodayEntry(id)
}
const existing = await findTodayEntryId(id)
if (existing) return existing
return createTodayEntry(logbookId)
return createTodayEntry(id)
}
export interface AppendQuickEventResult {
@@ -222,6 +260,35 @@ export async function appendQuickEvent(
return { events: nextEvents, hadSignature }
}
/** Append multiple events in one load/encrypt/persist cycle (avoids UI freezes). */
export async function appendQuickEvents(
logbookId: string,
entryId: string,
partialEvents: Partial<LogEventPayload>[]
): Promise<AppendQuickEventResult> {
const loaded = await loadEntry(logbookId, entryId)
if (!loaded) throw new Error('Entry not found')
const hadSignature = !!(loaded.data.signSkipper || loaded.data.signCrew)
const currentEvents = (loaded.data.events as LogEventPayload[]) || []
if (partialEvents.length === 0) {
return { events: currentEvents, hadSignature }
}
const time = currentLocalTimeHHMM()
const newEvents = partialEvents.map((partial) =>
normalizeLogEvent({ time, ...partial })
)
const nextEvents = sortLogEventsByTime([...currentEvents, ...newEvents])
await persistEntry(logbookId, entryId, loaded.data, {
events: nextEvents,
clearSignatures: hadSignature
})
return { events: nextEvents, hadSignature }
}
async function persistEntry(
logbookId: string,
entryId: string,
+61
View File
@@ -0,0 +1,61 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { PlausibleEvents } from './analytics.js'
const apiFetch = vi.fn()
const trackPlausibleEvent = vi.fn()
vi.mock('./api.js', () => ({ apiFetch }))
vi.mock('./analytics.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./analytics.js')>()
return {
...actual,
trackPlausibleEvent: (...args: unknown[]) => trackPlausibleEvent(...args)
}
})
vi.mock('./userPreferences.js', () => ({
getOwmApiKeyForActiveUser: () => ''
}))
describe('fetchOpenWeatherCurrent', () => {
beforeEach(() => {
apiFetch.mockReset()
trackPlausibleEvent.mockReset()
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('tracks OWM Weather Fetched on success when analyticsSource is set', async () => {
apiFetch.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ coord: { lat: 54, lon: 10 }, main: { temp: 20 } })
})
const { fetchOpenWeatherCurrent } = await import('./weather.js')
await fetchOpenWeatherCurrent(
{ lat: '54.0', lon: '10.0' },
{ analyticsSource: 'live_log' }
)
expect(trackPlausibleEvent).toHaveBeenCalledWith(PlausibleEvents.OWM_WEATHER_FETCHED, {
source: 'live_log'
})
})
it('does not track when the API request fails', async () => {
apiFetch.mockResolvedValue({
ok: false,
status: 500,
json: async () => ({ error: 'fail' })
})
const { fetchOpenWeatherCurrent, WeatherApiError } = await import('./weather.js')
await expect(
fetchOpenWeatherCurrent({ lat: '54', lon: '10' }, { analyticsSource: 'entry_editor' })
).rejects.toBeInstanceOf(WeatherApiError)
expect(trackPlausibleEvent).not.toHaveBeenCalled()
})
})
+37 -6
View File
@@ -1,5 +1,10 @@
import { apiFetch } from './api.js'
import { getOwmApiKeyForActiveUser } from './userPreferences.js'
import {
type OwmAnalyticsSource,
PlausibleEvents,
trackPlausibleEvent
} from './analytics.js'
export class WeatherApiError extends Error {
code: 'NO_KEY' | 'REQUEST_FAILED'
@@ -11,11 +16,16 @@ export class WeatherApiError extends Error {
}
}
export async function fetchOpenWeatherCurrent(params: {
lat?: string
lon?: string
q?: string
}): Promise<Record<string, unknown>> {
const OWM_FETCH_TIMEOUT_MS = 20_000
export async function fetchOpenWeatherCurrent(
params: {
lat?: string
lon?: string
q?: string
},
options?: { analyticsSource: OwmAnalyticsSource }
): Promise<Record<string, unknown>> {
const searchParams = new URLSearchParams()
if (params.lat && params.lon) {
@@ -31,7 +41,22 @@ export async function fetchOpenWeatherCurrent(params: {
const headers: Record<string, string> = {}
if (userKey) headers['X-OWM-Api-Key'] = userKey
const res = await apiFetch(`/api/weather/current?${searchParams.toString()}`, { headers })
const controller = new AbortController()
const timeoutId = window.setTimeout(() => controller.abort(), OWM_FETCH_TIMEOUT_MS)
let res: Response
try {
res = await apiFetch(`/api/weather/current?${searchParams.toString()}`, {
headers,
signal: controller.signal
})
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
throw new WeatherApiError('Weather request timed out')
}
throw err
} finally {
window.clearTimeout(timeoutId)
}
if (res.status === 503) {
throw new WeatherApiError('No OpenWeatherMap API key configured', 'NO_KEY')
@@ -42,5 +67,11 @@ export async function fetchOpenWeatherCurrent(params: {
throw new WeatherApiError('Weather API rejected the request')
}
if (options?.analyticsSource) {
trackPlausibleEvent(PlausibleEvents.OWM_WEATHER_FETCHED, {
source: options.analyticsSource
})
}
return data
}
@@ -0,0 +1,33 @@
import { describe, expect, it, vi } from 'vitest'
import { captureVideoFrame, preferNativeCameraPicker } from './captureVideoFrame.js'
describe('preferNativeCameraPicker', () => {
it('returns true on Android user agents', () => {
vi.stubGlobal('navigator', { ...navigator, userAgent: 'Mozilla/5.0 (Linux; Android 14)' })
expect(preferNativeCameraPicker()).toBe(true)
vi.unstubAllGlobals()
})
it('returns false on desktop without touch', () => {
vi.stubGlobal('navigator', {
...navigator,
userAgent: 'Mozilla/5.0 (Windows NT 10.0)',
maxTouchPoints: 0
})
vi.stubGlobal('matchMedia', () => ({
matches: false,
addEventListener: () => {},
removeEventListener: () => {}
}))
Object.defineProperty(window, 'ontouchstart', { value: undefined, configurable: true })
expect(preferNativeCameraPicker()).toBe(false)
vi.unstubAllGlobals()
})
})
describe('captureVideoFrame', () => {
it('throws when video dimensions are zero', async () => {
const video = { videoWidth: 0, videoHeight: 0 } as HTMLVideoElement
await expect(captureVideoFrame(video)).rejects.toThrow('video_frame_not_ready')
})
})
+59
View File
@@ -0,0 +1,59 @@
/** Capture current video frame as JPEG blob (with Android-safe fallbacks). */
export async function captureVideoFrame(video: HTMLVideoElement, quality = 0.92): Promise<Blob> {
const width = video.videoWidth
const height = video.videoHeight
if (!width || !height) {
throw new Error('video_frame_not_ready')
}
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (!ctx) {
throw new Error('canvas_context_unavailable')
}
ctx.drawImage(video, 0, 0, width, height)
const blob = await canvasToJpegBlob(canvas, quality)
if (blob) return blob
const dataUrl = canvas.toDataURL('image/jpeg', quality)
const response = await fetch(dataUrl)
const fallback = await response.blob()
if (!fallback.size) {
throw new Error('capture_encode_failed')
}
return fallback
}
function canvasToJpegBlob(canvas: HTMLCanvasElement, quality: number): Promise<Blob | null> {
return new Promise((resolve) => {
let settled = false
const finish = (blob: Blob | null) => {
if (settled) return
settled = true
window.clearTimeout(timer)
resolve(blob)
}
const timer = window.setTimeout(() => finish(null), 3000)
try {
canvas.toBlob((blob) => finish(blob), 'image/jpeg', quality)
} catch {
finish(null)
}
})
}
/** Mobile: native camera via file input is more reliable than getUserMedia + canvas. */
export function preferNativeCameraPicker(): boolean {
if (typeof window === 'undefined') return false
const ua = navigator.userAgent
if (/Android|iPhone|iPad|iPod/i.test(ua)) return true
const touch = 'ontouchstart' in window || navigator.maxTouchPoints > 0
const coarse = window.matchMedia('(pointer: coarse)').matches
const narrow = window.matchMedia('(max-width: 768px)').matches
return touch && (coarse || narrow)
}
@@ -6,6 +6,7 @@ import {
liveSailsRemark,
liveSogRemark,
parseLiveCommentRemark,
livePhotoRemark,
parseLiveSailsRemark
} from './liveEventCodes.js'
import { formatEventSummary } from './formatEventSummary.js'
@@ -24,6 +25,8 @@ const t = (key: string, opts?: Record<string, unknown>) => {
'logs.live_temp_entry': `Temperature ${opts?.temp} °C`,
'logs.live_pressure_entry': `Pressure ${opts?.value} hPa`,
'logs.live_wind_entry': `Wind ${opts?.value}`,
'logs.live_photo_entry': `Photo: ${opts?.caption}`,
'logs.live_photo_entry_plain': 'Photo captured',
'logs.live_course_entry': `Course ${opts?.course}`,
'logs.live_sog_entry': `SOG ${opts?.speed} kn`,
'logs.live_stw_entry': `STW ${opts?.speed} kn`,
@@ -106,4 +109,15 @@ describe('formatEventSummary', () => {
})
expect(formatEventSummary(event, t)).toBe('STW 4.8 kn')
})
it('formats photo entry', () => {
const plain = normalizeLogEvent({ time: '11:00', remarks: livePhotoRemark() })
expect(formatEventSummary(plain, t)).toBe('Photo captured')
const captioned = normalizeLogEvent({
time: '11:05',
remarks: livePhotoRemark('Mastbruch')
})
expect(formatEventSummary(captioned, t)).toBe('Photo: Mastbruch')
})
})
+8
View File
@@ -4,6 +4,7 @@ import {
LIVE_EVENT_CODES,
parseLiveCommentRemark,
parseLiveFuelRemark,
parseLivePhotoRemark,
parseLivePrecipRemark,
parseLiveSailsRemark,
parseLiveSogRemark,
@@ -26,6 +27,13 @@ export function formatEventSummary(event: LogEventPayload, t: TFunction): string
const comment = parseLiveCommentRemark(code)
if (comment) return comment
const photo = parseLivePhotoRemark(code)
if (photo !== null) {
return photo
? t('logs.live_photo_entry', { caption: photo })
: t('logs.live_photo_entry_plain')
}
const temp = parseLiveTempRemark(code)
if (temp) return t('logs.live_temp_entry', { temp })
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { normalizeGpsCoordinates, parseGpsCoordinate } from './geolocation.js'
describe('geolocation helpers', () => {
it('parses coordinates with comma decimals', () => {
expect(parseGpsCoordinate('54,123')).toBeCloseTo(54.123)
})
it('normalizes valid lat/lng', () => {
expect(normalizeGpsCoordinates('54.1', '10.2')).toEqual({
lat: '54.100000',
lng: '10.200000'
})
})
it('rejects out-of-range values', () => {
expect(normalizeGpsCoordinates('91', '0')).toBeNull()
expect(normalizeGpsCoordinates('0', '181')).toBeNull()
})
})
+19
View File
@@ -7,6 +7,25 @@ export interface GeoCoordinates {
speedKn: number | null
}
export function parseGpsCoordinate(value: string): number | null {
const trimmed = value.trim()
if (!trimmed) return null
const n = parseFloat(trimmed.replace(',', '.'))
return Number.isFinite(n) ? n : null
}
/** Validates lat/lng and returns normalized strings for storage, or null. */
export function normalizeGpsCoordinates(
lat: string,
lng: string
): { lat: string; lng: string } | null {
const latN = parseGpsCoordinate(lat)
const lngN = parseGpsCoordinate(lng)
if (latN == null || lngN == null) return null
if (latN < -90 || latN > 90 || lngN < -180 || lngN > 180) return null
return { lat: latN.toFixed(6), lng: lngN.toFixed(6) }
}
export function getCurrentPosition(timeoutMs = 15000): Promise<GeoCoordinates> {
return new Promise((resolve, reject) => {
if (!navigator.geolocation) {
+46
View File
@@ -0,0 +1,46 @@
export const PHOTO_MAX_WIDTH = 1280
export const PHOTO_MAX_HEIGHT = 720
export const PHOTO_JPEG_QUALITY = 0.7
function loadImageFromDataUrl(dataUrl: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => resolve(img)
img.onerror = () => reject(new Error('image_load_failed'))
img.src = dataUrl
})
}
export function compressImageElement(img: HTMLImageElement): string {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Could not get canvas context')
let width = img.width
let height = img.height
if (width > PHOTO_MAX_WIDTH || height > PHOTO_MAX_HEIGHT) {
const ratio = Math.min(PHOTO_MAX_WIDTH / width, PHOTO_MAX_HEIGHT / height)
width = Math.round(width * ratio)
height = Math.round(height * ratio)
}
canvas.width = width
canvas.height = height
ctx.drawImage(img, 0, 0, width, height)
return canvas.toDataURL('image/jpeg', PHOTO_JPEG_QUALITY)
}
export async function blobToCompressedJpegDataUrl(blob: Blob): Promise<string> {
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result))
reader.onerror = () => reject(new Error('image_read_failed'))
reader.readAsDataURL(blob)
})
const img = await loadImageFromDataUrl(dataUrl)
return compressImageElement(img)
}
export async function fileToCompressedJpegDataUrl(file: Blob): Promise<string> {
return blobToCompressedJpegDataUrl(file)
}
+64
View File
@@ -38,6 +38,17 @@ export function liveWaterRemark(liters: string): string {
return `__live:water:${liters}`
}
export function livePhotoRemark(caption?: string): string {
const text = caption?.trim()
return text ? `__live:photo:${text}` : '__live:photo'
}
export function parseLivePhotoRemark(remarks: string): string | null {
if (remarks === '__live:photo') return ''
const prefix = '__live:photo:'
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
}
export function liveSogRemark(speedKn: string): string {
return `__live:sog:${speedKn}`
}
@@ -120,3 +131,56 @@ export function getLastAutoPositionMs(
}
return null
}
/** Max age of a logged GPS fix for OpenWeatherMap lookups in live log. */
export const LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS = 6 * 60 * 60 * 1000
export type LiveLogPositionSource = 'fix' | 'auto_position'
export interface LiveLogPositionFix {
lat: string
lng: string
loggedAtMs: number
source: LiveLogPositionSource
}
function isPositionEventCode(code: string): boolean {
return code === LIVE_EVENT_CODES.FIX || code === LIVE_EVENT_CODES.AUTO_POSITION
}
/** Latest FIX or auto-position event with GPS coordinates (any age). */
export function getLatestPositionFix(
events: Array<{ remarks: string; time: string; gpsLat?: string; gpsLng?: string }>,
entryDate: string
): LiveLogPositionFix | null {
for (let i = events.length - 1; i >= 0; i--) {
const event = events[i]
const code = event.remarks.trim()
if (!isPositionEventCode(code)) continue
const lat = event.gpsLat?.trim()
const lng = event.gpsLng?.trim()
if (!lat || !lng) continue
const loggedAtMs = eventTimestampMs(entryDate, event.time)
if (loggedAtMs == null) continue
return {
lat,
lng,
loggedAtMs,
source: code === LIVE_EVENT_CODES.FIX ? 'fix' : 'auto_position'
}
}
return null
}
/** GPS fix for weather if logged within `maxAgeMs` (default 6 h). */
export function getLastPositionFixWithin(
events: Array<{ remarks: string; time: string; gpsLat?: string; gpsLng?: string }>,
entryDate: string,
maxAgeMs: number = LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS,
nowMs: number = Date.now()
): LiveLogPositionFix | null {
const latest = getLatestPositionFix(events, entryDate)
if (!latest) return null
if (nowMs - latest.loggedAtMs > maxAgeMs) return null
return latest
}
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import {
getLastPositionFixWithin,
getLatestPositionFix,
LIVE_EVENT_CODES,
LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS
} from './liveEventCodes.js'
const entryDate = '2026-06-01'
describe('live log position fix', () => {
it('returns latest fix with coordinates', () => {
const events = [
{ remarks: LIVE_EVENT_CODES.FIX, time: '08:00', gpsLat: '54.1', gpsLng: '10.2' },
{ remarks: LIVE_EVENT_CODES.FIX, time: '12:30', gpsLat: '54.2', gpsLng: '10.3' }
]
const fix = getLatestPositionFix(events, entryDate)
expect(fix?.lat).toBe('54.2')
expect(fix?.source).toBe('fix')
})
it('accepts auto-position with GPS', () => {
const events = [
{
remarks: LIVE_EVENT_CODES.AUTO_POSITION,
time: '14:00',
gpsLat: '55.0',
gpsLng: '11.0'
}
]
expect(getLatestPositionFix(events, entryDate)?.source).toBe('auto_position')
})
it('rejects fix older than max age for weather', () => {
const noon = new Date(`${entryDate}T12:00:00`).getTime()
const events = [
{ remarks: LIVE_EVENT_CODES.FIX, time: '05:00', gpsLat: '54.0', gpsLng: '10.0' }
]
expect(
getLastPositionFixWithin(events, entryDate, LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS, noon)
).toBeNull()
expect(getLatestPositionFix(events, entryDate)).not.toBeNull()
})
it('accepts fix within six hours', () => {
const noon = new Date(`${entryDate}T12:00:00`).getTime()
const events = [
{ remarks: LIVE_EVENT_CODES.FIX, time: '07:00', gpsLat: '54.0', gpsLng: '10.0' }
]
expect(
getLastPositionFixWithin(events, entryDate, LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS, noon)
).not.toBeNull()
})
})
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import {
formatWindStrengthBeaufort,
mpsToBeaufort,
parseOwmCurrentWeather
} from './openWeatherMap.js'
describe('openWeatherMap', () => {
it('maps m/s to Beaufort', () => {
expect(mpsToBeaufort(0)).toBe(0)
expect(mpsToBeaufort(5)).toBe(3)
expect(mpsToBeaufort(15)).toBe(7)
expect(formatWindStrengthBeaufort(5)).toBe('3 Bft (5.0 m/s)')
})
it('parses OWM current weather payload', () => {
const parsed = parseOwmCurrentWeather({
wind: { speed: 8.5, deg: 225 },
main: { pressure: 1018, temp: 17.4 },
weather: [{ icon: '04d', description: 'Bedeckt' }]
})
expect(parsed.windDirection).toBe('SW')
expect(parsed.windStrength).toBe('5 Bft (8.5 m/s)')
expect(parsed.windPressure).toBe('1018')
expect(parsed.tempC).toBe('17.4')
expect(parsed.precipText).toBe('Bedeckt')
expect(parsed.weatherIcon).toBe('04d')
})
})
+68
View File
@@ -0,0 +1,68 @@
import { degreesToCardinal } from './courseAngle.js'
export interface ParsedOwmCurrent {
windDirection: string
windStrength: string
windPressure: string
tempC: string | null
precipText: string | null
weatherIcon: string | null
}
/** Beaufort scale from wind speed in m/s (OWM `wind.speed`). */
export function mpsToBeaufort(mps: number): number {
if (mps < 0.3) return 0
if (mps < 1.6) return 1
if (mps < 3.4) return 2
if (mps < 5.5) return 3
if (mps < 8.0) return 4
if (mps < 10.8) return 5
if (mps < 13.9) return 6
if (mps < 17.2) return 7
if (mps < 20.8) return 8
if (mps < 24.5) return 9
if (mps < 28.5) return 10
if (mps < 32.7) return 11
return 12
}
export function formatWindStrengthBeaufort(mps: number): string {
const bft = mpsToBeaufort(mps)
return `${bft} Bft (${mps.toFixed(1)} m/s)`
}
export function parseOwmCurrentWeather(data: Record<string, unknown>): ParsedOwmCurrent {
const wind = data.wind as { speed?: number; deg?: number } | undefined
const main = data.main as { pressure?: number; temp?: number } | undefined
const rain = data.rain as { '1h'?: number } | undefined
const weatherArr = data.weather as Array<{ icon?: string; description?: string }> | undefined
const mps = wind?.speed ?? 0
const windStrength = formatWindStrengthBeaufort(mps)
const windDirection = wind?.deg !== undefined ? degreesToCardinal(wind.deg) : ''
const windPressure = main?.pressure != null ? String(main.pressure) : ''
let tempC: string | null = null
if (main?.temp != null && Number.isFinite(main.temp)) {
tempC = Number(main.temp).toFixed(1)
}
let precipText: string | null = null
const firstWeather = weatherArr?.[0]
if (firstWeather?.description?.trim()) {
precipText = firstWeather.description.trim()
} else if (rain?.['1h'] != null && Number.isFinite(rain['1h'])) {
precipText = `${rain['1h']} mm/h`
}
const weatherIcon = firstWeather?.icon?.trim() ? firstWeather.icon.trim() : null
return {
windDirection,
windStrength,
windPressure,
tempC,
precipText,
weatherIcon
}
}
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest'
import {
isPasskeyCompatibleHostname,
isPasskeyInvalidDomainError,
isWebAuthnUserAbortError,
localizeWebAuthnError,
toPasskeyCompatibleUrl
} from './passkeyHost.js'
describe('isPasskeyCompatibleHostname', () => {
it('accepts localhost and real domains', () => {
expect(isPasskeyCompatibleHostname('localhost')).toBe(true)
expect(isPasskeyCompatibleHostname('kapteins-daagbok.eu')).toBe(true)
})
it('rejects IP addresses', () => {
expect(isPasskeyCompatibleHostname('127.0.0.1')).toBe(false)
})
})
describe('toPasskeyCompatibleUrl', () => {
it('rewrites 127.0.0.1 to localhost', () => {
expect(toPasskeyCompatibleUrl('http://127.0.0.1:5173/demo?lng=de')).toBe(
'http://localhost:5173/demo?lng=de'
)
})
})
describe('isPasskeyInvalidDomainError', () => {
it('detects simplewebauthn browser message', () => {
expect(isPasskeyInvalidDomainError('127.0.0.1 is an invalid domain')).toBe(true)
expect(isPasskeyInvalidDomainError('User cancelled')).toBe(false)
})
})
describe('isWebAuthnUserAbortError', () => {
it('detects NotAllowedError and timeout messages', () => {
expect(isWebAuthnUserAbortError({ name: 'NotAllowedError', message: 'timed out' })).toBe(true)
expect(
isWebAuthnUserAbortError(
new Error('The operation either timed out or was not allowed.')
)
).toBe(true)
expect(isWebAuthnUserAbortError({ name: 'SecurityError', message: 'bad rp' })).toBe(false)
})
})
describe('localizeWebAuthnError', () => {
it('maps cancellation to a friendly message', () => {
expect(
localizeWebAuthnError('The operation either timed out or was not allowed.', {
invalidHost: 'host',
cancelled: 'cancelled'
})
).toBe('cancelled')
})
})
+69
View File
@@ -0,0 +1,69 @@
/**
* WebAuthn / Passkeys require a valid domain (see WHATWG valid domain).
* IP addresses such as 127.0.0.1 are rejected by browsers and @simplewebauthn/browser.
*/
export function isPasskeyCompatibleHostname(hostname: string): boolean {
return (
hostname === 'localhost' ||
/^((xn--[a-z0-9-]+|[a-z0-9]+(-[a-z0-9]+)*)\.)+([a-z]{2,}|xn--[a-z0-9-]+)$/i.test(hostname)
)
}
export function isPasskeyCompatibleLocation(loc: Location = window.location): boolean {
return isPasskeyCompatibleHostname(loc.hostname)
}
/** Same page on localhost — for dev links when opened via 127.0.0.1. */
export function toPasskeyCompatibleUrl(href: string): string {
const url = new URL(href)
if (url.hostname === '127.0.0.1' || url.hostname === '[::1]' || url.hostname === '::1') {
url.hostname = 'localhost'
}
return url.toString()
}
/**
* Redirect 127.0.0.1 / ::1 to localhost (dev). Returns true if navigation was started.
*/
export function redirectToPasskeyCompatibleHostIfNeeded(loc: Location = window.location): boolean {
if (isPasskeyCompatibleHostname(loc.hostname)) return false
const target = toPasskeyCompatibleUrl(loc.href)
if (target === loc.href) return false
window.location.replace(target)
return true
}
export function isPasskeyInvalidDomainError(message: string): boolean {
return /is an invalid domain$/i.test(message)
}
export function localizePasskeyHostError(message: string, invalidHostMessage: string): string {
return isPasskeyInvalidDomainError(message) ? invalidHostMessage : message
}
/** User dismissed or denied the platform passkey prompt (do not auto-retry WebAuthn). */
export function isWebAuthnUserAbortError(err: unknown): boolean {
if (!err || typeof err !== 'object') return false
const name = 'name' in err ? String((err as { name: string }).name) : ''
if (name === 'NotAllowedError' || name === 'AbortError') return true
const message = 'message' in err ? String((err as { message: string }).message) : String(err)
return /timed out|not allowed|cancel/i.test(message)
}
export function localizeWebAuthnError(
message: string,
messages: {
invalidHost: string
cancelled: string
invalidRpId?: string
}
): string {
if (isPasskeyInvalidDomainError(message)) return messages.invalidHost
if (/timed out|not allowed|cancel/i.test(message)) return messages.cancelled
if (/invalid for this domain/i.test(message) && messages.invalidRpId) {
return messages.invalidRpId
}
return message
}
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import {
dedupeSailNames,
isSailInSelection,
joinSailSelection,
splitSailSelection,
toggleSailInSelection
} from './sailSelection.js'
describe('toggleSailInSelection', () => {
it('adds a second sail without removing the first', () => {
const first = toggleSailInSelection([], 'Mainsail')
expect(first).toEqual(['Mainsail'])
const second = toggleSailInSelection(first, 'Genoa')
expect(second).toEqual(['Mainsail', 'Genoa'])
})
it('removes sail when toggled again', () => {
const selected = toggleSailInSelection(
toggleSailInSelection([], 'Mainsail'),
'Genoa'
)
expect(toggleSailInSelection(selected, 'Mainsail')).toEqual(['Genoa'])
})
it('matches case-insensitively', () => {
expect(toggleSailInSelection(['genua'], 'Genua')).toEqual([])
expect(isSailInSelection(['Großsegel'], 'großsegel')).toBe(true)
})
})
describe('joinSailSelection / splitSailSelection', () => {
it('round-trips multiple sails', () => {
const joined = joinSailSelection(['Großsegel', 'Genua'])
expect(joined).toBe('Großsegel + Genua')
expect(splitSailSelection(joined)).toEqual(['Großsegel', 'Genua'])
})
})
describe('dedupeSailNames', () => {
it('removes duplicate names', () => {
expect(dedupeSailNames(['Genua', 'genua', 'Fock'])).toEqual(['Genua', 'Fock'])
})
})
+42
View File
@@ -0,0 +1,42 @@
/** Toggle one sail label in a multi-select list (case-insensitive). */
export function toggleSailInSelection(selected: readonly string[], sail: string): string[] {
const normalized = sail.trim()
if (!normalized) return [...selected]
return selected.some((s) => s.toLowerCase() === normalized.toLowerCase())
? selected.filter((s) => s.toLowerCase() !== normalized.toLowerCase())
: [...selected, normalized]
}
export function isSailInSelection(selected: readonly string[], sail: string): boolean {
const normalized = sail.trim().toLowerCase()
if (!normalized) return false
return selected.some((s) => s.toLowerCase() === normalized)
}
/** Join selected sails for logbook `sailsOrMotor` (matches LogEntryEditor). */
export function joinSailSelection(selected: readonly string[]): string {
return selected.map((s) => s.trim()).filter(Boolean).join(' + ')
}
export function splitSailSelection(value: string): string[] {
return value
.split(/\s*(?:\+|\bplus\b|,)\s*/i)
.map((s) => s.trim())
.filter(Boolean)
}
/** Deduplicate sail names for picker UI (case-insensitive, keeps first spelling). */
export function dedupeSailNames(sails: readonly string[]): string[] {
const seen = new Set<string>()
const result: string[] = []
for (const sail of sails) {
const trimmed = sail.trim()
if (!trimmed) continue
const key = trimmed.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
result.push(trimmed)
}
return result
}
+2
View File
@@ -46,6 +46,8 @@ export default defineConfig({
include: ['leaflet']
},
server: {
// Passkeys require localhost or a real domain — not 127.0.0.1
host: 'localhost',
port: 5173,
proxy: {
'/api': {
+3 -1
View File
@@ -157,7 +157,7 @@
display: flex;
gap: 2.5mm;
align-items: flex-start;
font-size: 10.5pt;
font-size: 8.5pt;
line-height: 1.28;
color: #e2e8f0;
}
@@ -320,6 +320,8 @@
<div class="feature"><span class="feature-icon"></span><span>Simpelt login uden adgangskode Passkey.</span></div>
<div class="feature"><span class="feature-icon"></span><span>Ende-til-ende-kryptering</span></div>
<div class="feature"><span class="feature-icon"></span><span>Upload af GPS-spor (GPX/KML) med kortvisning</span></div>
<div class="feature"><span class="feature-icon"></span><span>Automatisk loggenerering fra NMEA-data</span></div>
<div class="feature"><span class="feature-icon"></span><span>Live-log (klik-til-log)</span></div>
<div class="feature"><span class="feature-icon"></span><span>Rute-statistik</span></div>
<div class="feature"><span class="feature-icon"></span><span>Vedhæftede billeder pr. rejsedag</span></div>
<div class="feature"><span class="feature-icon"></span><span>Fotoavatarbilleder til skipper og besætning</span></div>
+3 -1
View File
@@ -157,7 +157,7 @@
display: flex;
gap: 2.5mm;
align-items: flex-start;
font-size: 10.5pt;
font-size: 8.5pt;
line-height: 1.28;
color: #e2e8f0;
}
@@ -320,6 +320,8 @@
<div class="feature"><span class="feature-icon"></span><span>Einfache passwortlose Passkey-Anmeldung</span></div>
<div class="feature"><span class="feature-icon"></span><span>Ende-zu-Ende Verschlüsselung</span></div>
<div class="feature"><span class="feature-icon"></span><span>GPS-Track Upload (GPX/KML) mit Kartendarstellung</span></div>
<div class="feature"><span class="feature-icon"></span><span>Automatische Log-Erstellung aus NMEA-Daten</span></div>
<div class="feature"><span class="feature-icon"></span><span>Live-Log (Click-to-Log)</span></div>
<div class="feature"><span class="feature-icon"></span><span>Streckenstatistik</span></div>
<div class="feature"><span class="feature-icon"></span><span>Foto-Anhänge pro Reisetag</span></div>
<div class="feature"><span class="feature-icon"></span><span>Foto-Avatarbilder für Skipper und Crew</span></div>
+6 -4
View File
@@ -157,7 +157,7 @@
display: flex;
gap: 2.5mm;
align-items: flex-start;
font-size: 10.5pt;
font-size: 8.5pt;
line-height: 1.28;
color: #e2e8f0;
}
@@ -309,7 +309,7 @@
</header>
<p class="intro">
Oppbevar loggboken om bord digitalt: reisedager, GPS-spor, mannskaps- og skipsdata
r loggboken om bord digitalt: reisedager, GPS-spor, mannskaps- og skipsdata
<strong>Ende-til-ende-kryptert</strong>kan installeres som en app og
<strong>også offline</strong> kan brukes til sjøs.
</p>
@@ -320,6 +320,8 @@
<div class="feature"><span class="feature-icon"></span><span>Enkel passordfri Passkey-pålogging</span></div>
<div class="feature"><span class="feature-icon"></span><span>Ende-til-ende-kryptering</span></div>
<div class="feature"><span class="feature-icon"></span><span>Opplasting av GPS-spor (GPX/KML) med kartvisning</span></div>
<div class="feature"><span class="feature-icon"></span><span>Automatisk logggenerering fra NMEA-data</span></div>
<div class="feature"><span class="feature-icon"></span><span>Live-logg (klikk-til-logg)</span></div>
<div class="feature"><span class="feature-icon"></span><span>Rutestatistikk</span></div>
<div class="feature"><span class="feature-icon"></span><span>Fotobilag per reisedag</span></div>
<div class="feature"><span class="feature-icon"></span><span>Avatarbilder for skipper og mannskap</span></div>
@@ -349,7 +351,7 @@
</section>
<section class="beta-box">
<h2>Betafasen - dine tilbakemeldinger teller</h2>
<h2>Betafase - dine tilbakemeldinger teller</h2>
<p>
Kapteins Daagbok er en<strong>Privat hobbyprosjekt uten profitthensikt</strong>.
Som betatester bidrar du til å forbedre appen for skippere og mannskap i hverdagen - tilbakemeldinger er hjertelig velkomne.
@@ -366,7 +368,7 @@
<p>Åpne i nettleseren eller legg til som en app på startskjermen. Registrer deg med Passkey - ingen appbutikk er nødvendig.</p>
<div class="tags">
<span class="tag">Kostnadsfritt</span>
<span class="tag">Gratis annonsering</span>
<span class="tag">Reklame gratis</span>
<span class="tag">E2E-kryptert</span>
</div>
</div>
+3 -1
View File
@@ -157,7 +157,7 @@
display: flex;
gap: 2.5mm;
align-items: flex-start;
font-size: 10.5pt;
font-size: 8.5pt;
line-height: 1.28;
color: #e2e8f0;
}
@@ -320,6 +320,8 @@
<div class="feature"><span class="feature-icon"></span><span>Enkel lösenordsfri Passkey-inloggning</span></div>
<div class="feature"><span class="feature-icon"></span><span>End-to-end-kryptering</span></div>
<div class="feature"><span class="feature-icon"></span><span>Uppladdning av GPS-spår (GPX/KML) med kartvisning</span></div>
<div class="feature"><span class="feature-icon"></span><span>Automatisk logggenerering från NMEA-data</span></div>
<div class="feature"><span class="feature-icon"></span><span>Live-logg (klicka för att logga)</span></div>
<div class="feature"><span class="feature-icon"></span><span>Statistik över rutter</span></div>
<div class="feature"><span class="feature-icon"></span><span>Fotobilagor per resdag</span></div>
<div class="feature"><span class="feature-icon"></span><span>Fotoavatarbilder för skeppare och besättning</span></div>
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

+19 -2
View File
@@ -36,7 +36,9 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
| PDF Exported | PDF-Export eines Reisetags (`LogEntryEditor.tsx`, `LogEntriesList.tsx`) | `scope`: `entry` |
| CSV Exported | CSV-Download aus der Eintragsliste (`LogEntriesList.tsx`) | — |
| CSV Shared | CSV über Web Share API geteilt (`LogEntriesList.tsx`) | — |
| Photo Uploaded | Foto hochgeladen (`PhotoCapture.tsx`, `CrewForm.tsx`) | `context`: `logbook` \| `crew`, bei Crew zusätzlich `role`: `skipper` \| `crew` |
| Photo Uploaded | Foto hochgeladen (`photoAttachments.ts`, `PhotoCapture.tsx`, `CrewForm.tsx`) | `context`: `logbook` \| `live_log` \| `crew`, bei Crew zusätzlich `role`: `skipper` \| `crew` |
| Live Log Photo Uploaded | Foto im Live-Journal per Kamera gespeichert (`photoAttachments.ts`, `analyticsContext`: `live_log`) | — |
| OWM Weather Fetched | Erfolgreicher OpenWeatherMap-API-Abruf (`weather.ts`, zentral nach HTTP 200) | `source`: siehe [OWM-Quellen](#owm-quellen) |
| Backup Exported | Backup-Datei heruntergeladen (`LogbookBackupPanel.tsx`) | `entries`, `photos` (Anzahlen, keine Inhalte) |
| Backup Restored | Backup wiederhergestellt (`LogbookBackupPanel.tsx`) | `entries`, `photos`, `mode`: `same_id` \| `overwrite` \| `new_id` |
| Push Enabled | Crew-Änderungs-Push aktiviert (`PushNotificationSettings.tsx`) | — |
@@ -80,6 +82,18 @@ Property `action` bei **Live Log Event Logged** — stabile englische Schlüssel
| `comment` | Kommentar |
| `undo` | Letztes Ereignis rückgängig |
### OWM-Quellen
Property `source` bei **OWM Weather Fetched** — ein Event pro erfolgreichem API-Call (keine Koordinaten, kein Ortsname):
| `source` | Auslöser |
|----------|----------|
| `live_log` | OpenWeatherMap-Wetter im Live-Journal (`LiveLogView.tsx`) |
| `entry_editor` | Wetter-Button im Reisetag-Editor (`LogEntryEditor.tsx`, `handleFetchWeather`) |
| `entry_editor_gps_lookup` | GPS-Fallback per Ortsname im Reisetag-Editor (`LogEntryEditor.tsx`, `handleGetGps`) |
Fehlgeschlagene Abrufe (kein API-Key, Timeout, leere Antwort) lösen **kein** Event aus.
## Bewusst nicht getrackt
- **Demo-Logbuch:** Beim automatischen Seed (`demoLogbook.ts`) werden keine Events ausgelöst — nur echte Nutzeraktionen zählen.
@@ -106,7 +120,8 @@ Empfohlene Goal-Ketten für Auswertung (nur Business!):
7. **Kontosicherheit:** Profile Opened → Passkey Added / Local PIN Set / Recovery Rotated; Last Passkey Remove Hinted → Account Deleted (selten, aber aussagekräftig)
8. **Internationalisierung:** Language Changed (Verteilung `to`, Pfade mit Übersetzungs-Feedback)
9. **NMEA-Import:** NMEA Uploaded → NMEA Imported (Modus, `events`, optional Track; Upload-Funnel vs. Abbruch)
10. **Live-Journal:** Live Log Opened → Live Log Event Logged (Verteilung `action`; z. B. `fix`, `course`, `motor_start`)
10. **Live-Journal:** Live Log Opened → Live Log Event Logged (Verteilung `action`; z. B. `fix`, `course`, `motor_start`) → Live Log Photo Uploaded
11. **OpenWeatherMap:** OWM Weather Fetched (Verteilung `source`; Live-Journal vs. Reisetag-Editor)
## Entwicklung
@@ -117,6 +132,8 @@ trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'skipper' })
trackPlausibleEvent(PlausibleEvents.LANGUAGE_CHANGED, { from: 'de', to: 'da' })
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'course' })
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_PHOTO_UPLOADED)
trackPlausibleEvent(PlausibleEvents.OWM_WEATHER_FETCHED, { source: 'live_log' })
trackPlausibleEvent(PlausibleEvents.NMEA_UPLOADED, { lines: 1200, candidates: 8, duplicate: false, has_position: true })
trackPlausibleEvent(PlausibleEvents.NMEA_IMPORTED, { mode: 'both', events: 6, track: true })
```
+31 -4
View File
@@ -49,6 +49,21 @@ function parseColorSchemePreference(value: unknown): string | null {
return typeof value === 'string' && VALID_COLOR_SCHEMES.has(value) ? value : null
}
function isMissingAppearancePrefsTable(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code: string }).code === 'P2021'
)
}
const DEFAULT_APPEARANCE_PREFS = {
theme: 'auto',
colorScheme: 'auto',
persisted: false
} as const
router.post('/register-options', async (req, res) => {
try {
const { username } = req.body
@@ -448,9 +463,14 @@ router.get('/appearance-prefs', requireUser, async (req: any, res) => {
colorScheme: prefs?.colorScheme ?? 'auto',
persisted: prefs != null
})
} catch (error: any) {
} catch (error: unknown) {
if (isMissingAppearancePrefsTable(error)) {
console.warn('UserAppearancePrefs table missing — run: npx prisma db push (in server/)')
return res.json({ ...DEFAULT_APPEARANCE_PREFS })
}
console.error('Error reading appearance prefs:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
const message = error instanceof Error ? error.message : 'Internal server error'
return res.status(500).json({ error: message })
}
})
@@ -482,9 +502,16 @@ router.put('/appearance-prefs', requireUser, async (req: any, res) => {
colorScheme: prefs.colorScheme,
persisted: true
})
} catch (error: any) {
} catch (error: unknown) {
if (isMissingAppearancePrefsTable(error)) {
console.warn('UserAppearancePrefs table missing — run: npx prisma db push (in server/)')
return res.status(503).json({
error: 'Appearance preferences storage is not migrated. Run prisma db push on the server.'
})
}
console.error('Error updating appearance prefs:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
const message = error instanceof Error ? error.message : 'Internal server error'
return res.status(500).json({ error: message })
}
})