Compare commits

...

8 Commits

Author SHA1 Message Date
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
52 changed files with 2005 additions and 339 deletions
+4 -4
View File
@@ -5,13 +5,13 @@ OpenWeatherMapAPIKey=<owm_api_key>
DeepLAPIKey= DeepLAPIKey=
# Passkey configuration (WebAuthn Relying Party ID and Origin) # 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 # For production: e.g. kapteins-daagbok.eu and https://kapteins-daagbok.eu
RP_ID=localhost 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 ORIGIN=http://localhost:5173
# Optional: comma-separated CORS origins (defaults to ORIGIN; dev also allows 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,http://127.0.0.1:5173 # CORS_ORIGINS=http://localhost:5173
# API session signing (min. 32 chars; required in production) # API session signing (min. 32 chars; required in production)
# Generate: openssl rand -base64 48 # Generate: openssl rand -base64 48
+1 -1
View File
@@ -1 +1 @@
0.1.0.74 0.1.0.75
+12 -30
View File
@@ -17,6 +17,7 @@
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"lucide-react": "^1.16.0", "lucide-react": "^1.16.0",
"qrcode": "^1.5.4",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"react-i18next": "^17.0.8" "react-i18next": "^17.0.8"
@@ -25,6 +26,7 @@
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@types/leaflet": "^1.9.21", "@types/leaflet": "^1.9.21",
"@types/node": "^24.12.3", "@types/node": "^24.12.3",
"@types/qrcode": "^1.5.5",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.7.0", "@vitejs/plugin-react": "^4.7.0",
@@ -34,7 +36,6 @@
"globals": "^17.6.0", "globals": "^17.6.0",
"happy-dom": "^20.9.0", "happy-dom": "^20.9.0",
"playwright": "^1.51.0", "playwright": "^1.51.0",
"qrcode": "^1.5.4",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"typescript-eslint": "^8.59.2", "typescript-eslint": "^8.59.2",
"vite": "^6.3.5", "vite": "^6.3.5",
@@ -2970,6 +2971,16 @@
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
"license": "MIT" "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": { "node_modules/@types/raf": {
"version": "3.4.3", "version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
@@ -3461,7 +3472,6 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -3471,7 +3481,6 @@
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"color-convert": "^2.0.1" "color-convert": "^2.0.1"
@@ -3777,7 +3786,6 @@
"version": "5.3.1", "version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
@@ -3855,7 +3863,6 @@
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"string-width": "^4.2.0", "string-width": "^4.2.0",
@@ -3867,7 +3874,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"color-name": "~1.1.4" "color-name": "~1.1.4"
@@ -3880,7 +3886,6 @@
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/commander": { "node_modules/commander": {
@@ -4051,7 +4056,6 @@
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -4140,7 +4144,6 @@
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/dompurify": { "node_modules/dompurify": {
@@ -4195,7 +4198,6 @@
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/entities": { "node_modules/entities": {
@@ -4948,7 +4950,6 @@
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC", "license": "ISC",
"engines": { "engines": {
"node": "6.* || 8.* || >= 10.*" "node": "6.* || 8.* || >= 10.*"
@@ -5498,7 +5499,6 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -6208,7 +6208,6 @@
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
@@ -6231,7 +6230,6 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -6376,7 +6374,6 @@
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10.13.0" "node": ">=10.13.0"
@@ -6458,7 +6455,6 @@
"version": "1.5.4", "version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"dijkstrajs": "^1.0.1", "dijkstrajs": "^1.0.1",
@@ -6653,7 +6649,6 @@
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -6673,7 +6668,6 @@
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/resolve": { "node_modules/resolve": {
@@ -6845,7 +6839,6 @@
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/set-function-length": { "node_modules/set-function-length": {
@@ -7113,7 +7106,6 @@
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"emoji-regex": "^8.0.0", "emoji-regex": "^8.0.0",
@@ -7230,7 +7222,6 @@
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1" "ansi-regex": "^5.0.1"
@@ -8067,7 +8058,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/which-typed-array": { "node_modules/which-typed-array": {
@@ -8343,7 +8333,6 @@
"version": "6.2.0", "version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ansi-styles": "^4.0.0", "ansi-styles": "^4.0.0",
@@ -8380,7 +8369,6 @@
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/yallist": { "node_modules/yallist": {
@@ -8394,7 +8382,6 @@
"version": "15.4.1", "version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cliui": "^6.0.0", "cliui": "^6.0.0",
@@ -8417,7 +8404,6 @@
"version": "18.1.3", "version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"camelcase": "^5.0.0", "camelcase": "^5.0.0",
@@ -8431,7 +8417,6 @@
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"locate-path": "^5.0.0", "locate-path": "^5.0.0",
@@ -8445,7 +8430,6 @@
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"p-locate": "^4.1.0" "p-locate": "^4.1.0"
@@ -8458,7 +8442,6 @@
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"p-try": "^2.0.0" "p-try": "^2.0.0"
@@ -8474,7 +8457,6 @@
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"p-limit": "^2.2.0" "p-limit": "^2.2.0"
+3 -2
View File
@@ -29,12 +29,14 @@
"lucide-react": "^1.16.0", "lucide-react": "^1.16.0",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"react-i18next": "^17.0.8" "react-i18next": "^17.0.8",
"qrcode": "^1.5.4"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@types/leaflet": "^1.9.21", "@types/leaflet": "^1.9.21",
"@types/node": "^24.12.3", "@types/node": "^24.12.3",
"@types/qrcode": "^1.5.5",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.7.0", "@vitejs/plugin-react": "^4.7.0",
@@ -44,7 +46,6 @@
"globals": "^17.6.0", "globals": "^17.6.0",
"happy-dom": "^20.9.0", "happy-dom": "^20.9.0",
"playwright": "^1.51.0", "playwright": "^1.51.0",
"qrcode": "^1.5.4",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"typescript-eslint": "^8.59.2", "typescript-eslint": "^8.59.2",
"vite": "^6.3.5", "vite": "^6.3.5",
+175 -3
View File
@@ -1716,6 +1716,38 @@ html.scheme-dark .themed-select-option.is-selected {
min-width: 0; 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 { .form-actions--start {
justify-content: flex-start; justify-content: flex-start;
} }
@@ -3345,7 +3377,14 @@ html.theme-cupertino .events-scroll-container {
} }
.live-log-sail-pills { .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 { .live-log-modal-actions {
@@ -3413,6 +3452,12 @@ html.theme-cupertino .events-scroll-container {
cursor: pointer; 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) { .live-log-subaction-btn:hover:not(:disabled) {
color: var(--app-text); color: var(--app-text);
border-color: rgba(59, 130, 246, 0.3); border-color: rgba(59, 130, 246, 0.3);
@@ -3420,11 +3465,18 @@ html.theme-cupertino .events-scroll-container {
.live-log-undo-bar { .live-log-undo-bar {
position: fixed; position: fixed;
left: 50%; inset-inline: 0;
bottom: 24px; bottom: 24px;
transform: translateX(-50%);
z-index: 10060; z-index: 10060;
display: flex; display: flex;
justify-content: center;
padding-inline: 16px;
pointer-events: none;
}
.live-log-undo-bar-inner {
pointer-events: auto;
display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 10px 14px; padding: 10px 14px;
@@ -3433,6 +3485,126 @@ html.theme-cupertino .events-scroll-container {
border: 1px solid var(--app-border-muted); border: 1px solid var(--app-border-muted);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
font-size: 14px; 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;
} }
.stats-event-series-block + .stats-event-series-block { .stats-event-series-block + .stats-event-series-block {
+76 -18
View File
@@ -1,21 +1,28 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js' import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
import { import {
registerUser, registerUser,
loginUser, loginUser,
completeLoginWithRecovery, completeLoginWithRecovery,
setLocalPin, setLocalPin,
hasLocalPin, hasLocalPin,
decryptWithLocalPin, decryptWithLocalPin,
getActiveMasterKey, getActiveMasterKey,
getKnownUsernames, getKnownUsernames,
forgetUsername forgetUsername,
hasUnlockedLocalSession,
logoutUser
} from '../services/auth.js' } from '../services/auth.js'
import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react' import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react'
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx' import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
import DisclaimerModal from './DisclaimerModal.tsx' import DisclaimerModal from './DisclaimerModal.tsx'
import BetaBadge from './BetaBadge.tsx' import BetaBadge from './BetaBadge.tsx'
import {
isPasskeyCompatibleLocation,
localizeWebAuthnError,
toPasskeyCompatibleUrl
} from '../utils/passkeyHost.ts'
interface AuthOnboardingProps { interface AuthOnboardingProps {
onAuthenticated: () => void onAuthenticated: () => void
@@ -54,6 +61,16 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
const [showDisclaimer, setShowDisclaimer] = useState(false) const [showDisclaimer, setShowDisclaimer] = useState(false)
const [showHelp, setShowHelp] = 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 = () => { const finishAuth = () => {
if (isNewRegistration) { if (isNewRegistration) {
setShowDisclaimer(true) setShowDisclaimer(true)
@@ -81,7 +98,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
setRecoveryPhrase(result.recoveryPhrase) setRecoveryPhrase(result.recoveryPhrase)
} }
} catch (err: any) { } catch (err: any) {
setError(err.message || 'Registration failed') setError(formatAuthError(err.message || 'Registration failed'))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -121,7 +138,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
} }
} }
} catch (err: any) { } catch (err: any) {
setError(err.message || 'Login failed') setError(formatAuthError(err.message || 'Login failed'))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -185,19 +202,33 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
const handlePinLoginSubmit = async (e: React.FormEvent) => { const handlePinLoginSubmit = async (e: React.FormEvent) => {
e.preventDefault() 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) setLoading(true)
setError(null) setError(null)
try { try {
const resolvedUser = username.trim() || encryptedPayloads?.username
const key = await decryptWithLocalPin(pinLoginInput.trim(), resolvedUser) const key = await decryptWithLocalPin(pinLoginInput.trim(), resolvedUser)
if (key) { if (!key) {
onAuthenticated()
} else {
setError(t('auth.error_incorrect_pin')) 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')) setError(t('auth.error_incorrect_pin'))
} finally { } finally {
setLoading(false) setLoading(false)
@@ -361,6 +392,24 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
> >
{t('auth.use_recovery_instead')} {t('auth.use_recovery_instead')}
</button> </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> </div>
</form> </form>
</div> </div>
@@ -445,12 +494,21 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
</div> </div>
<div className="auth-form" style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: '20px' }}> <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 */} {/* Prominent Login button */}
<button <button
type="button" type="button"
className="btn primary" className="btn primary"
onClick={() => handleLogin()} onClick={() => handleLogin()}
disabled={loading} disabled={loading || !passkeyHostOk}
style={{ width: '100%', padding: '16px' }} style={{ width: '100%', padding: '16px' }}
> >
{loading {loading
@@ -583,7 +641,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
<button <button
type="submit" type="submit"
className="btn secondary" className="btn secondary"
disabled={loading || !username.trim()} disabled={loading || !username.trim() || !passkeyHostOk}
style={{ width: '100%' }} style={{ width: '100%' }}
> >
{t('auth.register')} {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>
)
}
+184
View File
@@ -0,0 +1,184 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Camera, X } from 'lucide-react'
interface LiveCameraCaptureProps {
open: boolean
busy?: boolean
caption?: string
onCaptionChange?: (value: string) => void
onClose: () => void
onCapture: (blob: Blob) => void
}
export default function LiveCameraCapture({
open,
busy = false,
caption = '',
onCaptionChange,
onClose,
onCapture
}: LiveCameraCaptureProps) {
const { t } = useTranslation()
const videoRef = useRef<HTMLVideoElement | null>(null)
const streamRef = useRef<MediaStream | null>(null)
const [cameraError, setCameraError] = useState<string | null>(null)
const [ready, setReady] = useState(false)
const stopStream = useCallback(() => {
for (const track of streamRef.current?.getTracks() ?? []) {
track.stop()
}
streamRef.current = null
if (videoRef.current) {
videoRef.current.srcObject = null
}
setReady(false)
}, [])
useEffect(() => {
if (!open) {
stopStream()
setCameraError(null)
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) {
video.srcObject = stream
await video.play()
setReady(true)
}
} catch (err) {
console.error('Camera access failed:', err)
if (!cancelled) {
setCameraError(t('logs.live_photo_camera_denied'))
}
}
}
void start()
return () => {
cancelled = true
stopStream()
}
}, [open, stopStream, t])
const handleCapture = () => {
const video = videoRef.current
if (!video || !ready || busy) return
const width = video.videoWidth
const height = video.videoHeight
if (!width || !height) return
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.drawImage(video, 0, 0, width, height)
canvas.toBlob(
(blob) => {
if (blob) onCapture(blob)
},
'image/jpeg',
0.92
)
}
if (!open) return null
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>
{cameraError ? (
<p className="live-log-modal-hint auth-error">{cameraError}</p>
) : (
<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>
<button
type="button"
className="btn primary live-camera-shutter"
onClick={handleCapture}
disabled={busy || !ready || !!cameraError}
>
<Camera size={18} />
{busy ? t('logs.photo_processing') : t('logs.live_photo_capture_btn')}
</button>
</div>
</div>
</div>
)
}
+472 -84
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 { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { import {
@@ -14,18 +14,19 @@ import {
Gauge, Gauge,
MapPin, MapPin,
MessageSquare, MessageSquare,
Camera,
Radio, Radio,
Sailboat, Sailboat,
Undo2, Undo2,
Zap Zap
} from 'lucide-react' } from 'lucide-react'
import { db } from '../services/db.js' import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js'
import { getLogbookKey } from '../services/logbookKeys.js' import { getLogbookKey } from '../services/logbookKeys.js'
import { decryptJson } from '../services/crypto.js' import { decryptJson } from '../services/crypto.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { import {
appendQuickEvent, appendQuickEvent,
appendQuickEvents,
appendTankRefill, appendTankRefill,
findOrCreateTodayEntry, findOrCreateTodayEntry,
loadEntry, loadEntry,
@@ -34,10 +35,14 @@ import {
import { formatEventSummary } from '../utils/formatEventSummary.js' import { formatEventSummary } from '../utils/formatEventSummary.js'
import { import {
getLastAutoPositionMs, getLastAutoPositionMs,
getLastPositionFixWithin,
getLatestPositionFix,
isMotorRunningFromEvents, isMotorRunningFromEvents,
LIVE_EVENT_CODES, LIVE_EVENT_CODES,
LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS,
liveCommentRemark, liveCommentRemark,
liveFuelRemark, liveFuelRemark,
livePhotoRemark,
livePrecipRemark, livePrecipRemark,
liveSailsRemark, liveSailsRemark,
liveSogRemark, liveSogRemark,
@@ -45,11 +50,21 @@ import {
liveTempRemark, liveTempRemark,
liveWaterRemark liveWaterRemark
} from '../utils/liveEventCodes.js' } 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 { sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayload.js'
import {
dedupeSailNames,
isSailInSelection,
joinSailSelection,
toggleSailInSelection
} from '../utils/sailSelection.js'
import { useDialog } from './ModalDialog.tsx' import { useDialog } from './ModalDialog.tsx'
import CourseDialInput from './CourseDialInput.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 { interface LiveLogViewProps {
logbookId: string logbookId: string
onOpenEditor: (entryId: string) => void onOpenEditor: (entryId: string) => void
@@ -70,11 +85,31 @@ type LiveModal =
| 'water' | 'water'
| 'sog' | 'sog'
| 'stw' | 'stw'
| 'fix'
| 'photo'
const AUTO_POSITION_INTERVAL_MS = 3 * 60 * 60 * 1000 const AUTO_POSITION_INTERVAL_MS = 3 * 60 * 60 * 1000
const AUTO_POSITION_CHECK_MS = 60_000 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 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() { function hapticPulse() {
navigator.vibrate?.(40) navigator.vibrate?.(40)
} }
@@ -113,77 +148,134 @@ export default function LiveLogView({
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [modal, setModal] = useState<LiveModal>('none') const [modal, setModal] = useState<LiveModal>('none')
const [weatherExpanded, setWeatherExpanded] = useState(false) const [weatherExpanded, setWeatherExpanded] = useState(false)
const [weatherOwmLoading, setWeatherOwmLoading] = useState(false)
const [commentText, setCommentText] = useState('') const [commentText, setCommentText] = useState('')
const [valueInput, setValueInput] = useState('') const [valueInput, setValueInput] = useState('')
const [valueInputSecondary, setValueInputSecondary] = useState('') const [valueInputSecondary, setValueInputSecondary] = useState('')
const [selectedSails, setSelectedSails] = useState<string[]>([]) const [selectedSails, setSelectedSails] = useState<string[]>([])
const [undoVisible, setUndoVisible] = useState(false) 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 streamEndRef = useRef<HTMLDivElement | null>(null)
const undoPhotoIdRef = useRef<string | null>(null)
const undoTimerRef = useRef<number | null>(null) const undoTimerRef = useRef<number | null>(null)
const autoPositionBusyRef = useRef(false) 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' const defaultSails = useMemo(
? ['Großsegel', 'Genua', 'Fock', 'Spinnaker', 'Gennaker'] () => (i18n.language === 'de'
: ['Mainsail', 'Genoa', 'Jib', 'Spinnaker', 'Gennaker'] ? ['Großsegel', 'Genua', 'Fock', 'Spinnaker', 'Gennaker']
const sailOptions = yachtSails.length > 0 ? yachtSails : defaultSails : ['Mainsail', 'Genoa', 'Jib', 'Spinnaker', 'Gennaker']),
[i18n.language]
)
const sailOptions = useMemo(
() => dedupeSailNames(yachtSails.length > 0 ? yachtSails : defaultSails),
[yachtSails, defaultSails]
)
const motorRunning = isMotorRunningFromEvents(events) const motorRunning = isMotorRunningFromEvents(events)
const motorLabel = t('logs.motor_propulsion') const motorLabel = t('logs.motor_propulsion')
const refreshEntry = useCallback(async (id: string) => { const applyLoadedEntry = useCallback((loaded: NonNullable<Awaited<ReturnType<typeof loadEntry>>>) => {
const loaded = await loadEntry(logbookId, id)
if (!loaded) return
const entryEvents = (loaded.data.events as LogEventPayload[]) || [] const entryEvents = (loaded.data.events as LogEventPayload[]) || []
setDayOfTravel(String(loaded.data.dayOfTravel || '')) setDayOfTravel(String(loaded.data.dayOfTravel || ''))
setDate(String(loaded.data.date || '')) setDate(String(loaded.data.date || ''))
setEvents(sortLogEventsByTime(entryEvents.map((e) => ({ ...e })))) 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) setUndoVisible(true)
if (undoTimerRef.current) window.clearTimeout(undoTimerRef.current) if (undoTimerRef.current) window.clearTimeout(undoTimerRef.current)
undoTimerRef.current = window.setTimeout(() => { undoTimerRef.current = window.setTimeout(() => {
setUndoVisible(false) setUndoVisible(false)
undoTimerRef.current = null undoTimerRef.current = null
undoPhotoIdRef.current = null
}, UNDO_TIMEOUT_MS) }, UNDO_TIMEOUT_MS)
}, []) }, [])
useEffect(() => { const runInit = useCallback(async () => {
let cancelled = false const seq = ++initSeqRef.current
setLoading(true)
setError(null)
setEntryId(null)
setEvents([])
setYachtSails([])
async function init() { if (!logbookId.trim()) {
setLoading(true) setError(t('logs.live_load_error'))
setError(null) setLoading(false)
try { return
const id = await findOrCreateTodayEntry(logbookId) }
if (cancelled) return
setEntryId(id)
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey() try {
if (masterKey) { const id = await withTimeout(
const yacht = await db.yachts.get(logbookId) findOrCreateTodayEntry(logbookId),
if (yacht) { LIVE_LOG_INIT_TIMEOUT_MS,
const decrypted = await decryptJson(yacht.encryptedData, yacht.iv, yacht.tag, masterKey) 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)) { if (decrypted?.sails && Array.isArray(decrypted.sails)) {
setYachtSails(decrypted.sails as string[]) setYachtSails(decrypted.sails as string[])
} }
} catch {
// Yacht profile optional for live log
} }
} }
}
await refreshEntry(id) const loaded = await loadEntry(logbookId, id)
} catch (err: unknown) { if (seq !== initSeqRef.current) return
if (!cancelled) { if (loaded) {
console.error('Failed to init live log:', err) applyLoadedEntry(loaded)
setError(err instanceof Error ? err.message : t('logs.live_load_error')) } else {
} throw new Error(t('logs.live_load_error'))
} finally { }
if (!cancelled) setLoading(false) } 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() useEffect(() => {
return () => { cancelled = true } void runInit()
}, [logbookId, refreshEntry, t]) return () => {
initSeqRef.current += 1
}
}, [runInit])
useEffect(() => { useEffect(() => {
if (!loading && entryId) { if (!loading && entryId) {
@@ -207,12 +299,12 @@ export default function LiveLogView({
const maybeAutoPosition = async () => { const maybeAutoPosition = async () => {
if (document.visibilityState !== 'visible' || autoPositionBusyRef.current || busy) return 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 if (lastMs != null && Date.now() - lastMs < AUTO_POSITION_INTERVAL_MS) return
autoPositionBusyRef.current = true autoPositionBusyRef.current = true
try { try {
const coords = await getCurrentPosition() const coords = await getCurrentPosition(8000)
await appendQuickEvent(logbookId, entryId, { await appendQuickEvent(logbookId, entryId, {
gpsLat: coords.lat, gpsLat: coords.lat,
gpsLng: coords.lng, gpsLng: coords.lng,
@@ -226,12 +318,20 @@ export default function LiveLogView({
} }
} }
const interval = window.setInterval(() => void maybeAutoPosition(), AUTO_POSITION_CHECK_MS) let intervalRef: number | undefined
return () => window.clearInterval(interval) const startTimer = window.setTimeout(() => {
}, [entryId, loading, events, date, logbookId, refreshEntry, busy]) 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 ( const runQuickAction = async (
action: () => Promise<void>, action: () => Promise<boolean | void>,
trackAction?: string, trackAction?: string,
withUndo = true withUndo = true
) => { ) => {
@@ -239,7 +339,8 @@ export default function LiveLogView({
setBusy(true) setBusy(true)
setError(null) setError(null)
try { try {
await action() const saved = await action()
if (saved === false) return
await refreshEntry(entryId) await refreshEntry(entryId)
if (withUndo) showUndo() if (withUndo) showUndo()
if (trackAction) { if (trackAction) {
@@ -296,40 +397,202 @@ export default function LiveLogView({
}, 'moor') }, '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 () => { void runQuickAction(async () => {
if (!entryId) return if (!entryId) return false
try { await appendQuickEvent(logbookId, entryId, {
const coords = await getCurrentPosition() gpsLat: coords.lat,
await appendQuickEvent(logbookId, entryId, { gpsLng: coords.lng,
gpsLat: coords.lat, remarks: LIVE_EVENT_CODES.FIX
gpsLng: coords.lng, })
remarks: LIVE_EVENT_CODES.FIX
})
} catch {
await showAlert(t('logs.live_gps_error'), t('logs.live_fix'))
}
}, '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 })
} 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()
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'weather_owm' })
} 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 = () => { const handleUndo = () => {
if (!entryId || busy) return if (!entryId || busy) return
const photoId = undoPhotoIdRef.current
setUndoVisible(false) setUndoVisible(false)
undoPhotoIdRef.current = null
if (undoTimerRef.current) { if (undoTimerRef.current) {
window.clearTimeout(undoTimerRef.current) window.clearTimeout(undoTimerRef.current)
undoTimerRef.current = null undoTimerRef.current = null
} }
void runQuickAction(async () => { void runQuickAction(async () => {
if (photoId) {
await deleteEntryPhoto(logbookId, photoId)
}
await removeLastEvent(logbookId, entryId) await removeLastEvent(logbookId, entryId)
}, 'undo', false) }, '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')
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: '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 = () => { const confirmSails = () => {
if (selectedSails.length === 0) { const sailsLabel = joinSailSelection(selectedSails)
if (!sailsLabel) {
setModal('none') setModal('none')
return return
} }
const sailsLabel = selectedSails.join(' + ')
setModal('none') setModal('none')
setSelectedSails([]) setSelectedSails([])
void runQuickAction(async () => { void runQuickAction(async () => {
@@ -468,18 +731,24 @@ export default function LiveLogView({
} }
const toggleSailSelection = (sail: string) => { const toggleSailSelection = (sail: string) => {
setSelectedSails((prev) => setSelectedSails((prev) => toggleSailInSelection(prev, sail))
prev.some((s) => s.toLowerCase() === sail.toLowerCase())
? prev.filter((s) => s.toLowerCase() !== sail.toLowerCase())
: [...prev, sail]
)
} }
const closeModal = () => setModal('none')
if (loading) { if (loading) {
return ( return (
<div className="tab-placeholder"> <div className="tab-placeholder">
<Radio className="header-logo spin" size={48} /> <Radio className="header-logo spin" size={48} />
<p>{t('logs.live_loading')}</p> <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> </div>
) )
} }
@@ -568,6 +837,15 @@ export default function LiveLogView({
</button> </button>
{weatherExpanded && ( {weatherExpanded && (
<div className="live-log-weather-submenu"> <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}> <button type="button" className="live-log-subaction-btn" onClick={() => openValueModal('wind', lastWindDirectionFromEvents(events))} disabled={busy}>
{t('logs.live_wind_btn')} {t('logs.live_wind_btn')}
</button> </button>
@@ -587,7 +865,7 @@ export default function LiveLogView({
)} )}
</div> </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} /> <MapPin size={18} />
{t('logs.live_fix')} {t('logs.live_fix')}
</button> </button>
@@ -595,6 +873,10 @@ export default function LiveLogView({
<MessageSquare size={18} /> <MessageSquare size={18} />
{t('logs.live_comment_btn')} {t('logs.live_comment_btn')}
</button> </button>
<button type="button" className="live-log-action-btn" onClick={openPhotoModal} disabled={busy || photoSaving}>
<Camera size={18} />
{t('logs.live_photo_btn')}
</button>
</aside> </aside>
<section className="live-log-stream-panel" aria-label={t('logs.live_stream_label')}> <section className="live-log-stream-panel" aria-label={t('logs.live_stream_label')}>
@@ -620,33 +902,130 @@ export default function LiveLogView({
<> <>
{undoVisible && events.length > 0 && ( {undoVisible && events.length > 0 && (
<div className="live-log-undo-bar" role="status"> <div className="live-log-undo-bar" role="status">
<span>{t('logs.live_undo_hint')}</span> <div className="live-log-undo-bar-inner">
<button type="button" className="btn secondary" onClick={handleUndo} disabled={busy}> <span>
<Undo2 size={16} /> {undoHint === 'photo' ? t('logs.live_undo_photo_hint') : t('logs.live_undo_hint')}
{t('logs.live_undo_btn')} </span>
</button> <button type="button" className="btn secondary" onClick={handleUndo} disabled={busy}>
<Undo2 size={16} />
{t('logs.live_undo_btn')}
</button>
</div>
</div> </div>
)} )}
{modal === 'sails' && ( {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()}> <div className="live-log-modal" onClick={(e) => e.stopPropagation()}>
<h3>{t('logs.live_sails_pick')}</h3> <h3>{t('logs.live_sails_pick')}</h3>
<div className="sails-picker-pills live-log-sail-pills"> <p className="live-log-modal-hint">{t('logs.live_sails_pick_hint')}</p>
{sailOptions.map((sail) => ( <div
<button className="sails-picker-pills live-log-sail-pills"
key={sail} role="group"
type="button" aria-label={t('logs.live_sails_pick')}
className={`sail-pill ${selectedSails.some((s) => s.toLowerCase() === sail.toLowerCase()) ? 'active' : ''}`} >
onClick={() => toggleSailSelection(sail)} {sailOptions.map((sail) => {
> const active = isSailInSelection(selectedSails, sail)
{sail} return (
</button> <button
))} key={sail}
type="button"
className={`sail-pill ${active ? 'active' : ''}`}
aria-pressed={active}
onClick={() => toggleSailSelection(sail)}
>
{sail}
</button>
)
})}
</div> </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"> <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 secondary" onClick={closeModal}>{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 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> </div>
</div> </div>
@@ -763,6 +1142,15 @@ export default function LiveLogView({
</div> </div>
</div> </div>
)} )}
<LiveCameraCapture
open={modal === 'photo'}
busy={photoSaving}
caption={photoCaption}
onCaptionChange={setPhotoCaption}
onClose={closePhotoModal}
onCapture={handlePhotoCapture}
/>
</>, </>,
document.body document.body
)} )}
+4 -2
View File
@@ -149,17 +149,19 @@ export default function LogEntriesList({
}, [logbookId, readOnly, preloadedEntries]) }, [logbookId, readOnly, preloadedEntries])
useEffect(() => { useEffect(() => {
if (viewMode === 'live') return
loadEntries() loadEntries()
}, [loadEntries]) }, [loadEntries, viewMode])
useEffect(() => { useEffect(() => {
if (viewMode === 'live') return
const prevSelectedEntryId = prevSelectedEntryIdRef.current const prevSelectedEntryId = prevSelectedEntryIdRef.current
prevSelectedEntryIdRef.current = selectedEntryId prevSelectedEntryIdRef.current = selectedEntryId
if (prevSelectedEntryId !== undefined && prevSelectedEntryId !== null && selectedEntryId === null) { if (prevSelectedEntryId !== undefined && prevSelectedEntryId !== null && selectedEntryId === null) {
loadEntries() loadEntries()
} }
}, [selectedEntryId, loadEntries]) }, [selectedEntryId, loadEntries, viewMode])
const handleDownloadCsv = async () => { const handleDownloadCsv = async () => {
setExporting(true) setExporting(true)
+6 -33
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 { buildLogEntryPayload, sortLogEventsByTime, normalizeLogEvent, hasUnsavedEventDraft, currentLocalTimeHHMM, isValidTimeHHMM, type LogEventPayload } from '../utils/logEntryPayload.js'
import EventTimeInput24h from './EventTimeInput24h.tsx' import EventTimeInput24h from './EventTimeInput24h.tsx'
import CourseDialInput from './CourseDialInput.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 { hashEntryForSigning } from '../utils/entryCanonicalHash.js'
import { signLogEntry } from '../services/entrySigning.js' import { signLogEntry } from '../services/entrySigning.js'
import { getLogbookAccess } from '../services/logbookAccess.js' import { getLogbookAccess } from '../services/logbookAccess.js'
@@ -965,38 +965,11 @@ export default function LogEntryEditor({
setEvGpsLng(Number(coord.lon).toFixed(6)) setEvGpsLng(Number(coord.lon).toFixed(6))
} }
const wind = data.wind as { speed?: number; deg?: number } | undefined const parsed = parseOwmCurrentWeather(data)
const main = data.main as { pressure?: number } | undefined setEvWindStrength(parsed.windStrength)
setEvWindPressure(parsed.windPressure)
// Convert wind speed m/s to Beaufort scale if (parsed.windDirection) setEvWindDirection(parsed.windDirection)
const mps = wind?.speed || 0 if (parsed.weatherIcon) setEvWeatherIcon(parsed.weatherIcon)
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)
}
showAlert(t('settings.weather_success')) showAlert(t('settings.weather_success'))
} catch (err) { } catch (err) {
+21 -100
View File
@@ -3,9 +3,9 @@ import { useTranslation } from 'react-i18next'
import { db } from '../services/db.js' import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js' import { getActiveMasterKey } from '../services/auth.js'
import { getLogbookKey } from '../services/logbookKeys.js' import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson, decryptJson } from '../services/crypto.js' import { decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js' import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { fileToCompressedJpegDataUrl } from '../utils/imageCompress.js'
import { useLiveQuery } from 'dexie-react-hooks' import { useLiveQuery } from 'dexie-react-hooks'
import { useDialog } from './ModalDialog.tsx' import { useDialog } from './ModalDialog.tsx'
import { Camera, Trash2 } from 'lucide-react' import { Camera, Trash2 } from 'lucide-react'
@@ -90,109 +90,30 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
setUploading(true) setUploading(true)
setError(null) setError(null)
const reader = new FileReader() try {
reader.onload = (event) => { const compressedBase64 = await fileToCompressedJpegDataUrl(file)
const img = new Image() await saveEntryPhoto({
img.onload = async () => { logbookId,
try { entryId,
const canvas = document.createElement('canvas') imageDataUrl: compressedBase64,
const ctx = canvas.getContext('2d') caption: caption.trim(),
if (!ctx) throw new Error('Could not get canvas context') analyticsContext: 'logbook'
})
let width = img.width setCaption('')
let height = img.height if (fileInputRef.current) fileInputRef.current.value = ''
const MAX_WIDTH = 1280 } catch (err: unknown) {
const MAX_HEIGHT = 720 console.error('Failed to process image:', err)
setError(err instanceof Error ? err.message : 'Failed to process image')
// Calculate resizing conserving aspect ratio } finally {
if (width > MAX_WIDTH || height > MAX_HEIGHT) { setUploading(false)
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
} }
reader.readAsDataURL(file)
} }
const handleDelete = async (photoId: string) => { 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'))) { if (await showConfirm(t('logs.photo_delete_confirm'), t('logs.photos_title'), t('logs.confirm_yes'), t('logs.confirm_no'))) {
try { try {
const now = new Date().toISOString() await deleteEntryPhoto(logbookId, photoId)
} catch (err: unknown) {
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) {
console.error('Failed to delete photo:', err) 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 { Settings as SettingsIcon, Check, Users, Trash2, Copy, Link as LinkIcon } from 'lucide-react'
import { ensureLogbookKey } from '../services/logbookKeys.js' import { ensureLogbookKey } from '../services/logbookKeys.js'
import LogbookBackupPanel from './LogbookBackupPanel.tsx' import LogbookBackupPanel from './LogbookBackupPanel.tsx'
import LinkQrCode from './LinkQrCode.tsx'
import { useDialog } from './ModalDialog.tsx' import { useDialog } from './ModalDialog.tsx'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { apiFetch } from '../services/api.js' import { apiFetch } from '../services/api.js'
@@ -314,23 +315,27 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
</div> </div>
{shareEnabled && shareLink && ( {shareEnabled && shareLink && (
<div className="input-group mb-4 copy-link-row"> <div className="link-with-qr mb-4">
<input <div className="input-group copy-link-row">
type="text" <input
readOnly type="text"
value={shareLink} readOnly
className="input-text font-mono text-xs" value={shareLink}
style={{ flex: 1, padding: '10px' }} className="input-text font-mono text-xs"
onClick={(e) => (e.target as HTMLInputElement).select()} style={{ flex: 1, padding: '10px' }}
/> onClick={(e) => (e.target as HTMLInputElement).select()}
<button />
type="button" <button
className="btn secondary" type="button"
onClick={handleCopyShareLink} className="btn secondary"
style={{ width: 'auto', padding: '10px' }} onClick={handleCopyShareLink}
> style={{ width: 'auto', padding: '10px' }}
{shareCopied ? <Check size={16} /> : <Copy size={16} />} title={t('settings.share_copy_btn')}
</button> >
{shareCopied ? <Check size={16} /> : <Copy size={16} />}
</button>
</div>
<LinkQrCode value={shareLink} />
</div> </div>
)} )}
</div> </div>
@@ -367,23 +372,27 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
</div> </div>
{inviteLink && ( {inviteLink && (
<div className="input-group mb-6 copy-link-row"> <div className="link-with-qr mb-6">
<input <div className="input-group copy-link-row">
type="text" <input
readOnly type="text"
value={inviteLink} readOnly
className="input-text font-mono text-xs" value={inviteLink}
style={{ flex: 1, padding: '10px' }} className="input-text font-mono text-xs"
onClick={(e) => (e.target as HTMLInputElement).select()} style={{ flex: 1, padding: '10px' }}
/> onClick={(e) => (e.target as HTMLInputElement).select()}
<button />
type="button" <button
className="btn secondary" type="button"
onClick={handleCopyInvite} className="btn secondary"
style={{ width: 'auto', padding: '10px' }} onClick={handleCopyInvite}
> style={{ width: 'auto', padding: '10px' }}
{inviteCopied ? <Check size={16} /> : <Copy size={16} />} title={t('settings.share_copy_btn')}
</button> >
{inviteCopied ? <Check size={16} /> : <Copy size={16} />}
</button>
</div>
<LinkQrCode value={inviteLink} />
</div> </div>
)} )}
+30 -1
View File
@@ -68,7 +68,12 @@
"enter_pin_placeholder": "Indtast din pinkode...", "enter_pin_placeholder": "Indtast din pinkode...",
"decrypt_with_pin": "Afkodning", "decrypt_with_pin": "Afkodning",
"use_recovery_instead": "Brug genoprettelsesnøgler i stedet", "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": { "pwa": {
"title": "Installer app", "title": "Installer app",
@@ -202,6 +207,7 @@
"live_mode": "Live", "live_mode": "Live",
"live_title": "Live-journal", "live_title": "Live-journal",
"live_loading": "Live-journal indlæses...", "live_loading": "Live-journal indlæses...",
"live_retry": "Prøv igen",
"live_load_error": "Live-journal kunne ikke indlæses.", "live_load_error": "Live-journal kunne ikke indlæses.",
"live_action_error": "Indtastning kunne ikke gemmes.", "live_action_error": "Indtastning kunne ikke gemmes.",
"live_open_editor": "Fuld editor", "live_open_editor": "Fuld editor",
@@ -215,16 +221,37 @@
"live_moor": "Anløb", "live_moor": "Anløb",
"live_sails_btn": "Sejl", "live_sails_btn": "Sejl",
"live_sails_pick": "Vælg 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": "Indtast",
"live_sails_confirm_count": "Indtast ({{count}})",
"live_sails": "Sejl: {{sails}}", "live_sails": "Sejl: {{sails}}",
"live_fix": "Fix", "live_fix": "Fix",
"live_fix_coords": "Fix {{lat}}, {{lng}}", "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_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_btn": "Kommentar",
"live_comment_placeholder": "Indtast tekst…", "live_comment_placeholder": "Indtast tekst…",
"live_comment_confirm": "Indtast", "live_comment_confirm": "Indtast",
"live_gps_error": "GPS-position kunne ikke bestemmes.", "live_gps_error": "GPS-position kunne ikke bestemmes.",
"live_event_generic": "Hændelse", "live_event_generic": "Hændelse",
"live_weather_btn": "Vejr", "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_wind_btn": "Vind",
"live_temp_btn": "T °C", "live_temp_btn": "T °C",
"live_pressure_btn": "Lufttryk", "live_pressure_btn": "Lufttryk",
@@ -606,6 +633,8 @@
"share_enable": "Aktivér offentligt link", "share_enable": "Aktivér offentligt link",
"share_copied": "Link kopieret!", "share_copied": "Link kopieret!",
"share_copy_btn": "Kopier link", "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_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.", "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", "delete_account_btn": "Slet konto uigenkaldeligt",
+31 -2
View File
@@ -68,7 +68,12 @@
"enter_pin_placeholder": "Gib deine PIN ein...", "enter_pin_placeholder": "Gib deine PIN ein...",
"decrypt_with_pin": "Entschlüsseln", "decrypt_with_pin": "Entschlüsseln",
"use_recovery_instead": "Stattdessen Wiederherstellungsschlüssel verwenden", "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": { "pwa": {
"title": "App installieren", "title": "App installieren",
@@ -202,6 +207,7 @@
"live_mode": "Live", "live_mode": "Live",
"live_title": "Live-Journal", "live_title": "Live-Journal",
"live_loading": "Live-Journal wird geladen...", "live_loading": "Live-Journal wird geladen...",
"live_retry": "Erneut versuchen",
"live_load_error": "Live-Journal konnte nicht geladen werden.", "live_load_error": "Live-Journal konnte nicht geladen werden.",
"live_action_error": "Eintrag konnte nicht gespeichert werden.", "live_action_error": "Eintrag konnte nicht gespeichert werden.",
"live_open_editor": "Vollständiger Editor", "live_open_editor": "Vollständiger Editor",
@@ -214,17 +220,38 @@
"live_cast_off": "Ablegen", "live_cast_off": "Ablegen",
"live_moor": "Anlegen", "live_moor": "Anlegen",
"live_sails_btn": "Segel", "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": "Eintragen",
"live_sails_confirm_count": "Eintragen ({{count}})",
"live_sails": "Segel: {{sails}}", "live_sails": "Segel: {{sails}}",
"live_fix": "Fix", "live_fix": "Fix",
"live_fix_coords": "Fix {{lat}}, {{lng}}", "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_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_btn": "Kommentar",
"live_comment_placeholder": "Freitext eingeben…", "live_comment_placeholder": "Freitext eingeben…",
"live_comment_confirm": "Eintragen", "live_comment_confirm": "Eintragen",
"live_gps_error": "GPS-Position konnte nicht ermittelt werden.", "live_gps_error": "GPS-Position konnte nicht ermittelt werden.",
"live_event_generic": "Ereignis", "live_event_generic": "Ereignis",
"live_weather_btn": "Wetter", "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_wind_btn": "Wind",
"live_temp_btn": "T °C", "live_temp_btn": "T °C",
"live_pressure_btn": "Luftdruck", "live_pressure_btn": "Luftdruck",
@@ -606,6 +633,8 @@
"share_enable": "Öffentlichen Link aktivieren", "share_enable": "Öffentlichen Link aktivieren",
"share_copied": "Link kopiert!", "share_copied": "Link kopiert!",
"share_copy_btn": "Link kopieren", "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_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.", "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", "delete_account_btn": "Konto unwiderruflich löschen",
+30 -1
View File
@@ -68,7 +68,12 @@
"enter_pin_placeholder": "Enter your PIN...", "enter_pin_placeholder": "Enter your PIN...",
"decrypt_with_pin": "Decrypt", "decrypt_with_pin": "Decrypt",
"use_recovery_instead": "Use recovery phrase instead", "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": { "pwa": {
"title": "Install app", "title": "Install app",
@@ -202,6 +207,7 @@
"live_mode": "Live", "live_mode": "Live",
"live_title": "Live Journal", "live_title": "Live Journal",
"live_loading": "Loading live journal...", "live_loading": "Loading live journal...",
"live_retry": "Try again",
"live_load_error": "Could not load live journal.", "live_load_error": "Could not load live journal.",
"live_action_error": "Could not save entry.", "live_action_error": "Could not save entry.",
"live_open_editor": "Full editor", "live_open_editor": "Full editor",
@@ -215,16 +221,37 @@
"live_moor": "Moor", "live_moor": "Moor",
"live_sails_btn": "Sails", "live_sails_btn": "Sails",
"live_sails_pick": "Select 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": "Log entry",
"live_sails_confirm_count": "Log entry ({{count}})",
"live_sails": "Sails: {{sails}}", "live_sails": "Sails: {{sails}}",
"live_fix": "Fix", "live_fix": "Fix",
"live_fix_coords": "Fix {{lat}}, {{lng}}", "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_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_btn": "Comment",
"live_comment_placeholder": "Enter text…", "live_comment_placeholder": "Enter text…",
"live_comment_confirm": "Log entry", "live_comment_confirm": "Log entry",
"live_gps_error": "Could not determine GPS position.", "live_gps_error": "Could not determine GPS position.",
"live_event_generic": "Event", "live_event_generic": "Event",
"live_weather_btn": "Weather", "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_wind_btn": "Wind",
"live_temp_btn": "Temp °C", "live_temp_btn": "Temp °C",
"live_pressure_btn": "Pressure", "live_pressure_btn": "Pressure",
@@ -606,6 +633,8 @@
"share_enable": "Enable Public Link", "share_enable": "Enable Public Link",
"share_copied": "Link copied!", "share_copied": "Link copied!",
"share_copy_btn": "Copy Link", "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_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.", "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", "delete_account_btn": "Permanently Delete Account",
+30 -1
View File
@@ -68,7 +68,12 @@
"enter_pin_placeholder": "Tast inn PIN-koden din...", "enter_pin_placeholder": "Tast inn PIN-koden din...",
"decrypt_with_pin": "Dekryptere", "decrypt_with_pin": "Dekryptere",
"use_recovery_instead": "Bruk gjenopprettingsnøkler i stedet", "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": { "pwa": {
"title": "Installer app", "title": "Installer app",
@@ -202,6 +207,7 @@
"live_mode": "Live", "live_mode": "Live",
"live_title": "Live-journal", "live_title": "Live-journal",
"live_loading": "Live-journal lastes inn...", "live_loading": "Live-journal lastes inn...",
"live_retry": "Prøv igjen",
"live_load_error": "Live-journal kunne ikke lastes inn.", "live_load_error": "Live-journal kunne ikke lastes inn.",
"live_action_error": "Oppføringen kunne ikke lagres.", "live_action_error": "Oppføringen kunne ikke lagres.",
"live_open_editor": "Full editor", "live_open_editor": "Full editor",
@@ -215,16 +221,37 @@
"live_moor": "Anløp", "live_moor": "Anløp",
"live_sails_btn": "Seil", "live_sails_btn": "Seil",
"live_sails_pick": "Velg 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": "Loggfør",
"live_sails_confirm_count": "Loggfør ({{count}})",
"live_sails": "Seil: {{sails}}", "live_sails": "Seil: {{sails}}",
"live_fix": "Fix", "live_fix": "Fix",
"live_fix_coords": "Fix {{lat}}, {{lng}}", "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_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_btn": "Kommentar",
"live_comment_placeholder": "Skriv inn tekst…", "live_comment_placeholder": "Skriv inn tekst…",
"live_comment_confirm": "Loggfør", "live_comment_confirm": "Loggfør",
"live_gps_error": "GPS-posisjon kunne ikke bestemmes.", "live_gps_error": "GPS-posisjon kunne ikke bestemmes.",
"live_event_generic": "Hendelse", "live_event_generic": "Hendelse",
"live_weather_btn": "Vær", "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_wind_btn": "Vind",
"live_temp_btn": "T °C", "live_temp_btn": "T °C",
"live_pressure_btn": "Lufttrykk", "live_pressure_btn": "Lufttrykk",
@@ -606,6 +633,8 @@
"share_enable": "Aktiver offentlig lenke", "share_enable": "Aktiver offentlig lenke",
"share_copied": "Linken er kopiert!", "share_copied": "Linken er kopiert!",
"share_copy_btn": "Kopier lenke", "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_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.", "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", "delete_account_btn": "Slett konto ugjenkallelig",
+30 -1
View File
@@ -68,7 +68,12 @@
"enter_pin_placeholder": "Ange din PIN-kod...", "enter_pin_placeholder": "Ange din PIN-kod...",
"decrypt_with_pin": "Dekryptera", "decrypt_with_pin": "Dekryptera",
"use_recovery_instead": "Använd återställningsnycklar istället", "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": { "pwa": {
"title": "Installera app", "title": "Installera app",
@@ -202,6 +207,7 @@
"live_mode": "Live", "live_mode": "Live",
"live_title": "Live-journal", "live_title": "Live-journal",
"live_loading": "Live-journal laddas...", "live_loading": "Live-journal laddas...",
"live_retry": "Försök igen",
"live_load_error": "Live-journal kunde inte laddas.", "live_load_error": "Live-journal kunde inte laddas.",
"live_action_error": "Posten kunde inte sparas.", "live_action_error": "Posten kunde inte sparas.",
"live_open_editor": "Fullständig editor", "live_open_editor": "Fullständig editor",
@@ -215,16 +221,37 @@
"live_moor": "Anlöp", "live_moor": "Anlöp",
"live_sails_btn": "Segel", "live_sails_btn": "Segel",
"live_sails_pick": "Välj 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": "Logga",
"live_sails_confirm_count": "Logga ({{count}})",
"live_sails": "Segel: {{sails}}", "live_sails": "Segel: {{sails}}",
"live_fix": "Fix", "live_fix": "Fix",
"live_fix_coords": "Fix {{lat}}, {{lng}}", "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_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_btn": "Kommentar",
"live_comment_placeholder": "Ange text…", "live_comment_placeholder": "Ange text…",
"live_comment_confirm": "Logga", "live_comment_confirm": "Logga",
"live_gps_error": "GPS-position kunde inte bestämmas.", "live_gps_error": "GPS-position kunde inte bestämmas.",
"live_event_generic": "Händelse", "live_event_generic": "Händelse",
"live_weather_btn": "Väder", "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_wind_btn": "Vind",
"live_temp_btn": "T °C", "live_temp_btn": "T °C",
"live_pressure_btn": "Lufttryck", "live_pressure_btn": "Lufttryck",
@@ -606,6 +633,8 @@
"share_enable": "Aktivera offentlig länk", "share_enable": "Aktivera offentlig länk",
"share_copied": "Länk kopierad!", "share_copied": "Länk kopierad!",
"share_copy_btn": "Kopiera länk", "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_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.", "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", "delete_account_btn": "Ta bort konto oåterkalleligt",
+5
View File
@@ -12,6 +12,7 @@ import {
markReloadAttempt, markReloadAttempt,
reconcileVersionOnStartup reconcileVersionOnStartup
} from './services/pwaStartup.ts' } from './services/pwaStartup.ts'
import { redirectToPasskeyCompatibleHostIfNeeded } from './utils/passkeyHost.ts'
/** Stale PWA precache on localhost can shadow Vite dev modules. */ /** Stale PWA precache on localhost can shadow Vite dev modules. */
async function clearDevServiceWorkerCaches(): Promise<void> { async function clearDevServiceWorkerCaches(): Promise<void> {
@@ -40,6 +41,10 @@ function renderBootstrapError(message: string): void {
} }
async function bootstrap(): Promise<void> { async function bootstrap(): Promise<void> {
if (redirectToPasskeyCompatibleHostIfNeeded()) {
return
}
applyAppearanceToDocument() applyAppearanceToDocument()
installStaleAssetRecovery() installStaleAssetRecovery()
await clearDevServiceWorkerCaches() await clearDevServiceWorkerCaches()
+6 -1
View File
@@ -12,6 +12,7 @@ import { clearLogbookKeysCache } from './logbookKeys.js'
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js' import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
import { db } from './db.js' import { db } from './db.js'
import { apiFetch, apiJson } from './api.js' import { apiFetch, apiJson } from './api.js'
import { isWebAuthnUserAbortError } from '../utils/passkeyHost.js'
const API_BASE = '/api/auth' const API_BASE = '/api/auth'
@@ -361,7 +362,11 @@ export async function loginUser(username?: string): Promise<LoginResult> {
const prfRequested = !!options.extensions?.prf const prfRequested = !!options.extensions?.prf
try { try {
credentialResponse = await startAuthentication({ optionsJSON: options }) 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) { if (prfRequested) {
console.warn('Passkey authentication with PRF extension failed, retrying without PRF:', err) console.warn('Passkey authentication with PRF extension failed, retrying without PRF:', err)
if (options.extensions) { if (options.extensions) {
+4
View File
@@ -214,6 +214,10 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
if (response.ok) { if (response.ok) {
const serverLb = await response.json() const serverLb = await response.json()
if (serverLb.id !== localId) {
await saveLogbookKey(serverLb.id, logbookKey)
await db.logbookKeys.delete(localId)
}
await db.logbooks.put({ await db.logbooks.put({
id: serverLb.id, id: serverLb.id,
encryptedTitle: serverLb.encryptedTitle, encryptedTitle: serverLb.encryptedTitle,
+89
View File
@@ -0,0 +1,89 @@
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 })
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 { db } from './db.js'
import { getActiveMasterKey } from './auth.js' import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js' import { ensureLogbookKey, getLogbookKey } from './logbookKeys.js'
import { decryptJson, encryptJson } from './crypto.js' import { decryptJson, encryptJson } from './crypto.js'
import { syncLogbook } from './sync.js' import { syncLogbook } from './sync.js'
import { import {
@@ -24,12 +24,36 @@ export interface LoadedEntry {
data: Record<string, unknown> data: Record<string, unknown>
} }
type EncryptedRecord = {
encryptedData: string
iv: string
tag: string
}
async function getMasterKey(logbookId: string): Promise<ArrayBuffer> { async function getMasterKey(logbookId: string): Promise<ArrayBuffer> {
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey() const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.') if (!masterKey) throw new Error('Encryption key not found. Please log in.')
return masterKey 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>) { function tankLevelsFromData(data: Record<string, unknown>) {
const fw = (data.freshwater as Record<string, number> | undefined) ?? { const fw = (data.freshwater as Record<string, number> | undefined) ?? {
morning: 0, refilled: 0, evening: 0, consumption: 0 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 masterKey = await getMasterKey(logbookId)
const record = await db.entries.get(entryId) const record = await db.entries.get(entryId)
if (!record) return null 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 if (!data) return null
return { payloadId: record.payloadId, updatedAt: record.updatedAt, data } 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> { export async function findTodayEntryId(logbookId: string): Promise<string | null> {
const todayStr = new Date().toISOString().substring(0, 10) const todayStr = new Date().toISOString().substring(0, 10)
const masterKey = await getMasterKey(logbookId) 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) { 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) { if (decrypted && String(decrypted.date) === todayStr) {
return entry.payloadId return entry.payloadId
} }
@@ -134,9 +158,13 @@ export async function createTodayEntry(logbookId: string): Promise<string> {
const localEntries = await db.entries.where({ logbookId }).toArray() const localEntries = await db.entries.where({ logbookId }).toArray()
const decryptedEntries: Array<LogEntryTankSource & TravelDaySortable> = [] const decryptedEntries: Array<LogEntryTankSource & TravelDaySortable> = []
for (const entry of localEntries) { if (localEntries.length > 0) {
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey) for (const entry of localEntries) {
if (decrypted) decryptedEntries.push(decrypted as LogEntryTankSource & TravelDaySortable) const decrypted = await tryDecryptEntryPayload(entry, masterKey)
if (decrypted) {
decryptedEntries.push(decrypted as LogEntryTankSource & TravelDaySortable)
}
}
} }
decryptedEntries.sort(compareTravelDaysChronological) decryptedEntries.sort(compareTravelDaysChronological)
@@ -185,9 +213,19 @@ export async function createTodayEntry(logbookId: string): Promise<string> {
} }
export async function findOrCreateTodayEntry(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 if (existing) return existing
return createTodayEntry(logbookId) return createTodayEntry(id)
} }
export interface AppendQuickEventResult { export interface AppendQuickEventResult {
@@ -222,6 +260,35 @@ export async function appendQuickEvent(
return { events: nextEvents, hadSignature } 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( async function persistEntry(
logbookId: string, logbookId: string,
entryId: string, entryId: string,
+18 -1
View File
@@ -11,6 +11,8 @@ export class WeatherApiError extends Error {
} }
} }
const OWM_FETCH_TIMEOUT_MS = 20_000
export async function fetchOpenWeatherCurrent(params: { export async function fetchOpenWeatherCurrent(params: {
lat?: string lat?: string
lon?: string lon?: string
@@ -31,7 +33,22 @@ export async function fetchOpenWeatherCurrent(params: {
const headers: Record<string, string> = {} const headers: Record<string, string> = {}
if (userKey) headers['X-OWM-Api-Key'] = userKey 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) { if (res.status === 503) {
throw new WeatherApiError('No OpenWeatherMap API key configured', 'NO_KEY') throw new WeatherApiError('No OpenWeatherMap API key configured', 'NO_KEY')
@@ -6,6 +6,7 @@ import {
liveSailsRemark, liveSailsRemark,
liveSogRemark, liveSogRemark,
parseLiveCommentRemark, parseLiveCommentRemark,
livePhotoRemark,
parseLiveSailsRemark parseLiveSailsRemark
} from './liveEventCodes.js' } from './liveEventCodes.js'
import { formatEventSummary } from './formatEventSummary.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_temp_entry': `Temperature ${opts?.temp} °C`,
'logs.live_pressure_entry': `Pressure ${opts?.value} hPa`, 'logs.live_pressure_entry': `Pressure ${opts?.value} hPa`,
'logs.live_wind_entry': `Wind ${opts?.value}`, '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_course_entry': `Course ${opts?.course}`,
'logs.live_sog_entry': `SOG ${opts?.speed} kn`, 'logs.live_sog_entry': `SOG ${opts?.speed} kn`,
'logs.live_stw_entry': `STW ${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') 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, LIVE_EVENT_CODES,
parseLiveCommentRemark, parseLiveCommentRemark,
parseLiveFuelRemark, parseLiveFuelRemark,
parseLivePhotoRemark,
parseLivePrecipRemark, parseLivePrecipRemark,
parseLiveSailsRemark, parseLiveSailsRemark,
parseLiveSogRemark, parseLiveSogRemark,
@@ -26,6 +27,13 @@ export function formatEventSummary(event: LogEventPayload, t: TFunction): string
const comment = parseLiveCommentRemark(code) const comment = parseLiveCommentRemark(code)
if (comment) return comment 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) const temp = parseLiveTempRemark(code)
if (temp) return t('logs.live_temp_entry', { temp }) 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 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> { export function getCurrentPosition(timeoutMs = 15000): Promise<GeoCoordinates> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!navigator.geolocation) { 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}` 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 { export function liveSogRemark(speedKn: string): string {
return `__live:sog:${speedKn}` return `__live:sog:${speedKn}`
} }
@@ -120,3 +131,56 @@ export function getLastAutoPositionMs(
} }
return null 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'] include: ['leaflet']
}, },
server: { server: {
// Passkeys require localhost or a real domain — not 127.0.0.1
host: 'localhost',
port: 5173, port: 5173,
proxy: { proxy: {
'/api': { '/api': {
+3 -1
View File
@@ -157,7 +157,7 @@
display: flex; display: flex;
gap: 2.5mm; gap: 2.5mm;
align-items: flex-start; align-items: flex-start;
font-size: 10.5pt; font-size: 8.5pt;
line-height: 1.28; line-height: 1.28;
color: #e2e8f0; 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>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>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>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>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>Vedhæftede billeder pr. rejsedag</span></div>
<div class="feature"><span class="feature-icon"></span><span>Fotoavatarbilleder til skipper og besætning</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; display: flex;
gap: 2.5mm; gap: 2.5mm;
align-items: flex-start; align-items: flex-start;
font-size: 10.5pt; font-size: 8.5pt;
line-height: 1.28; line-height: 1.28;
color: #e2e8f0; 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>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>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>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>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-Anhänge pro Reisetag</span></div>
<div class="feature"><span class="feature-icon"></span><span>Foto-Avatarbilder für Skipper und Crew</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; display: flex;
gap: 2.5mm; gap: 2.5mm;
align-items: flex-start; align-items: flex-start;
font-size: 10.5pt; font-size: 8.5pt;
line-height: 1.28; line-height: 1.28;
color: #e2e8f0; color: #e2e8f0;
} }
@@ -309,7 +309,7 @@
</header> </header>
<p class="intro"> <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>Ende-til-ende-kryptert</strong>kan installeres som en app og
<strong>også offline</strong> kan brukes til sjøs. <strong>også offline</strong> kan brukes til sjøs.
</p> </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>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>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>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>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>Fotobilag per reisedag</span></div>
<div class="feature"><span class="feature-icon"></span><span>Avatarbilder for skipper og mannskap</span></div> <div class="feature"><span class="feature-icon"></span><span>Avatarbilder for skipper og mannskap</span></div>
@@ -349,7 +351,7 @@
</section> </section>
<section class="beta-box"> <section class="beta-box">
<h2>Betafasen - dine tilbakemeldinger teller</h2> <h2>Betafase - dine tilbakemeldinger teller</h2>
<p> <p>
Kapteins Daagbok er en<strong>Privat hobbyprosjekt uten profitthensikt</strong>. 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. 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> <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"> <div class="tags">
<span class="tag">Kostnadsfritt</span> <span class="tag">Kostnadsfritt</span>
<span class="tag">Gratis annonsering</span> <span class="tag">Reklame gratis</span>
<span class="tag">E2E-kryptert</span> <span class="tag">E2E-kryptert</span>
</div> </div>
</div> </div>
+3 -1
View File
@@ -157,7 +157,7 @@
display: flex; display: flex;
gap: 2.5mm; gap: 2.5mm;
align-items: flex-start; align-items: flex-start;
font-size: 10.5pt; font-size: 8.5pt;
line-height: 1.28; line-height: 1.28;
color: #e2e8f0; 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>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>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>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>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>Fotobilagor per resdag</span></div>
<div class="feature"><span class="feature-icon"></span><span>Fotoavatarbilder för skeppare och besättning</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

+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 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) => { router.post('/register-options', async (req, res) => {
try { try {
const { username } = req.body const { username } = req.body
@@ -448,9 +463,14 @@ router.get('/appearance-prefs', requireUser, async (req: any, res) => {
colorScheme: prefs?.colorScheme ?? 'auto', colorScheme: prefs?.colorScheme ?? 'auto',
persisted: prefs != null 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) 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, colorScheme: prefs.colorScheme,
persisted: true 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) 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 })
} }
}) })