Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a8ec2fccf | |||
| 60a8533a44 | |||
| c86ac4273c | |||
| 73467f2263 | |||
| e068f083c1 | |||
| f083294db5 | |||
| 8fc15081e2 | |||
| efa0fcf934 | |||
| c1ecdcad9c | |||
| d6c7952af8 | |||
| 3d02f841a0 | |||
| 0caaf681d8 | |||
| 43dc994c4f | |||
| d94502097e | |||
| a36ca2facb | |||
| b7a1085d52 | |||
| 3925c6f822 | |||
| 0b2c1c22c6 | |||
| aa03573e1f | |||
| a0b8664e23 | |||
| 74282f50d0 | |||
| 5b47415d55 | |||
| 039e4e2736 | |||
| 35bfbc1043 |
@@ -5,13 +5,13 @@ OpenWeatherMapAPIKey=<owm_api_key>
|
||||
DeepLAPIKey=
|
||||
|
||||
# Passkey configuration (WebAuthn Relying Party ID and Origin)
|
||||
# For local dev: localhost and http://localhost
|
||||
# For local dev: use localhost (NOT 127.0.0.1 — browsers reject IP addresses for Passkeys)
|
||||
# For production: e.g. kapteins-daagbok.eu and https://kapteins-daagbok.eu
|
||||
RP_ID=localhost
|
||||
# Must match the frontend URL (Vite dev: http://localhost:5173; Docker: http://localhost)
|
||||
# Must match the frontend URL exactly (Vite dev: http://localhost:5173; Docker: http://localhost)
|
||||
ORIGIN=http://localhost:5173
|
||||
# Optional: comma-separated CORS origins (defaults to ORIGIN; dev also allows 127.0.0.1:5173)
|
||||
# CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
||||
# Optional: comma-separated CORS origins (defaults to ORIGIN; 127.0.0.1 may be allowed for CORS but not for login)
|
||||
# CORS_ORIGINS=http://localhost:5173
|
||||
|
||||
# API session signing (min. 32 chars; required in production)
|
||||
# Generate: openssl rand -base64 48
|
||||
|
||||
@@ -11,3 +11,5 @@ server/dist/
|
||||
.env.local
|
||||
.env.*.local
|
||||
*.log
|
||||
|
||||
userfeedback/
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"jspdf": "^4.2.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^1.16.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-i18next": "^17.0.8"
|
||||
@@ -25,6 +26,7 @@
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^24.12.3",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
@@ -34,7 +36,6 @@
|
||||
"globals": "^17.6.0",
|
||||
"happy-dom": "^20.9.0",
|
||||
"playwright": "^1.51.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.59.2",
|
||||
"vite": "^6.3.5",
|
||||
@@ -2970,6 +2971,16 @@
|
||||
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/qrcode": {
|
||||
"version": "1.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/raf": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
|
||||
@@ -3461,7 +3472,6 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -3471,7 +3481,6 @@
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
@@ -3777,7 +3786,6 @@
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@@ -3855,7 +3863,6 @@
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
@@ -3867,7 +3874,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
@@ -3880,7 +3886,6 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
@@ -4051,7 +4056,6 @@
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -4140,7 +4144,6 @@
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
@@ -4195,7 +4198,6 @@
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/entities": {
|
||||
@@ -4948,7 +4950,6 @@
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
@@ -5498,7 +5499,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -6208,7 +6208,6 @@
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@@ -6231,7 +6230,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -6376,7 +6374,6 @@
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
@@ -6458,7 +6455,6 @@
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
@@ -6653,7 +6649,6 @@
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -6673,7 +6668,6 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
@@ -6845,7 +6839,6 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/set-function-length": {
|
||||
@@ -7113,7 +7106,6 @@
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
@@ -7230,7 +7222,6 @@
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
@@ -8067,7 +8058,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/which-typed-array": {
|
||||
@@ -8343,7 +8333,6 @@
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
@@ -8380,7 +8369,6 @@
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
@@ -8394,7 +8382,6 @@
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
@@ -8417,7 +8404,6 @@
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
@@ -8431,7 +8417,6 @@
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
@@ -8445,7 +8430,6 @@
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
@@ -8458,7 +8442,6 @@
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
@@ -8474,7 +8457,6 @@
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
|
||||
@@ -29,12 +29,14 @@
|
||||
"lucide-react": "^1.16.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-i18next": "^17.0.8"
|
||||
"react-i18next": "^17.0.8",
|
||||
"qrcode": "^1.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^24.12.3",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
@@ -44,7 +46,6 @@
|
||||
"globals": "^17.6.0",
|
||||
"happy-dom": "^20.9.0",
|
||||
"playwright": "^1.51.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.59.2",
|
||||
"vite": "^6.3.5",
|
||||
|
||||
@@ -1716,6 +1716,38 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.link-with-qr {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.link-qr-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
border-radius: var(--app-radius-card, 12px);
|
||||
background: var(--app-surface-inset, rgba(0, 0, 0, 0.2));
|
||||
border: 1px solid var(--app-border-subtle);
|
||||
}
|
||||
|
||||
.link-qr-label {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--app-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.link-qr-image {
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
padding: 8px;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
.form-actions--start {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
@@ -3167,6 +3199,482 @@ html.theme-cupertino .events-scroll-container {
|
||||
color: #38bdf8;
|
||||
}
|
||||
|
||||
/* Live log journal mode */
|
||||
.logs-view-toggle {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.logs-view-toggle-btn.is-active {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
border-color: rgba(59, 130, 246, 0.45);
|
||||
color: var(--app-accent-light, #93c5fd);
|
||||
}
|
||||
|
||||
.live-log-card {
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
.live-log-subtitle {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--app-text-muted);
|
||||
}
|
||||
|
||||
.live-log-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(148px, 200px) 1fr;
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.live-log-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.live-log-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--app-radius-btn, 10px);
|
||||
border: 1px solid var(--app-border-muted);
|
||||
background: var(--app-surface);
|
||||
color: var(--app-text);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.live-log-action-btn:hover:not(:disabled) {
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
border-color: rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.live-log-action-btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.live-log-action-btn.is-active {
|
||||
background: rgba(251, 191, 36, 0.15);
|
||||
border-color: rgba(251, 191, 36, 0.45);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.live-log-stream-panel {
|
||||
min-height: 280px;
|
||||
border: 1px solid var(--app-border-muted);
|
||||
border-radius: var(--app-radius-card, 12px);
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.live-log-stream-title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--app-accent-light);
|
||||
}
|
||||
|
||||
.live-log-empty {
|
||||
margin: 0;
|
||||
color: var(--app-text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.live-log-stream {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-height: min(60vh, 520px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.live-log-entry {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--app-border-muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.live-log-entry:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.live-log-time {
|
||||
flex-shrink: 0;
|
||||
min-width: 3.25rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
color: var(--app-text-muted);
|
||||
}
|
||||
|
||||
.live-log-summary {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.live-log-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10050;
|
||||
background: rgba(2, 6, 23, 0.78);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.live-log-modal {
|
||||
width: min(420px, 100%);
|
||||
padding: 20px;
|
||||
border-radius: var(--app-radius-card, 12px);
|
||||
background: var(--app-surface-alt);
|
||||
border: 1px solid var(--app-border-muted);
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.live-log-modal--dial {
|
||||
width: min(320px, 100%);
|
||||
}
|
||||
|
||||
.live-log-dial-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
border-radius: var(--app-radius-input, 8px);
|
||||
background: var(--app-surface-inset);
|
||||
border: 1px solid var(--app-border-subtle);
|
||||
}
|
||||
|
||||
.live-log-dial-field label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--app-text-muted);
|
||||
}
|
||||
|
||||
.live-log-modal h3 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.live-log-modal-hint {
|
||||
margin: -8px 0 12px;
|
||||
font-size: 13px;
|
||||
color: var(--app-text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.live-log-sail-pills {
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.live-log-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.live-log-actions {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.live-log-action-btn {
|
||||
width: auto;
|
||||
flex: 1 1 calc(50% - 4px);
|
||||
min-width: 140px;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.live-log-weather-group {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.live-log-weather-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.live-log-weather-toggle {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.live-log-weather-toggle.is-expanded {
|
||||
border-color: rgba(59, 130, 246, 0.35);
|
||||
}
|
||||
|
||||
.live-log-weather-submenu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.live-log-subaction-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--app-border-muted);
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
color: var(--app-text-muted);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.live-log-subaction-btn-owm {
|
||||
border-color: rgba(59, 130, 246, 0.35);
|
||||
color: var(--app-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.live-log-subaction-btn:hover:not(:disabled) {
|
||||
color: var(--app-text);
|
||||
border-color: rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.live-log-undo-bar {
|
||||
position: fixed;
|
||||
inset-inline: 0;
|
||||
bottom: 24px;
|
||||
z-index: 10060;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-inline: 16px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.live-log-undo-bar-inner {
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
background: var(--app-surface-alt);
|
||||
border: 1px solid var(--app-border-muted);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
|
||||
font-size: 14px;
|
||||
max-width: min(100%, 420px);
|
||||
}
|
||||
|
||||
.live-log-fix-coords {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.live-log-fix-label {
|
||||
display: block;
|
||||
margin: 0 0 10px;
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--app-text-muted);
|
||||
}
|
||||
|
||||
.live-log-fix-coords-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.live-log-fix-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.live-log-fix-field-label {
|
||||
font-size: 12px;
|
||||
color: var(--app-text-muted);
|
||||
}
|
||||
|
||||
.live-log-fix-field .input-text {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.live-log-fix-gps-row {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.live-log-fix-gps-btn {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.live-camera-modal {
|
||||
width: min(480px, 100%);
|
||||
}
|
||||
|
||||
.live-camera-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.live-camera-header h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.live-camera-close {
|
||||
width: auto;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.live-camera-preview-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
border-radius: var(--app-radius-input, 8px);
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.live-camera-preview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.live-camera-loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--app-text-muted);
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.live-camera-caption {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.live-camera-actions {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.live-camera-shutter {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.live-camera-file-input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.live-camera-preview-still {
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.live-camera-native-prompt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.live-camera-open-native {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.live-camera-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stats-event-series-block + .stats-event-series-block {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.stats-event-series-list {
|
||||
list-style: none;
|
||||
margin: 8px 0 0;
|
||||
padding: 0;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.stats-event-series-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid var(--app-border-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.stats-event-series-when {
|
||||
color: var(--app-text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stats-event-series-value {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.grid-span-2 {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
|
||||
import {
|
||||
registerUser,
|
||||
loginUser,
|
||||
completeLoginWithRecovery,
|
||||
setLocalPin,
|
||||
hasLocalPin,
|
||||
decryptWithLocalPin,
|
||||
import {
|
||||
registerUser,
|
||||
loginUser,
|
||||
completeLoginWithRecovery,
|
||||
setLocalPin,
|
||||
hasLocalPin,
|
||||
decryptWithLocalPin,
|
||||
getActiveMasterKey,
|
||||
getKnownUsernames,
|
||||
forgetUsername
|
||||
forgetUsername,
|
||||
hasUnlockedLocalSession,
|
||||
logoutUser
|
||||
} from '../services/auth.js'
|
||||
import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react'
|
||||
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
|
||||
import DisclaimerModal from './DisclaimerModal.tsx'
|
||||
import BetaBadge from './BetaBadge.tsx'
|
||||
import {
|
||||
isPasskeyCompatibleLocation,
|
||||
localizeWebAuthnError,
|
||||
toPasskeyCompatibleUrl
|
||||
} from '../utils/passkeyHost.ts'
|
||||
|
||||
interface AuthOnboardingProps {
|
||||
onAuthenticated: () => void
|
||||
@@ -54,6 +61,16 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
||||
const [showDisclaimer, setShowDisclaimer] = useState(false)
|
||||
const [showHelp, setShowHelp] = useState(false)
|
||||
|
||||
const passkeyHostOk = isPasskeyCompatibleLocation()
|
||||
const passkeyCompatibleUrl = passkeyHostOk ? null : toPasskeyCompatibleUrl(window.location.href)
|
||||
|
||||
const formatAuthError = (message: string) =>
|
||||
localizeWebAuthnError(message, {
|
||||
invalidHost: t('auth.error_invalid_host'),
|
||||
cancelled: t('auth.error_passkey_cancelled'),
|
||||
invalidRpId: t('auth.error_invalid_rp_id')
|
||||
})
|
||||
|
||||
const finishAuth = () => {
|
||||
if (isNewRegistration) {
|
||||
setShowDisclaimer(true)
|
||||
@@ -81,7 +98,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
||||
setRecoveryPhrase(result.recoveryPhrase)
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Registration failed')
|
||||
setError(formatAuthError(err.message || 'Registration failed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -121,7 +138,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Login failed')
|
||||
setError(formatAuthError(err.message || 'Login failed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -185,19 +202,33 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
||||
|
||||
const handlePinLoginSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!pinLoginInput.trim()) return
|
||||
if (!pinLoginInput.trim() || loading) return
|
||||
|
||||
const resolvedUser =
|
||||
username.trim() ||
|
||||
encryptedPayloads?.username ||
|
||||
localStorage.getItem('active_username') ||
|
||||
''
|
||||
if (!resolvedUser) {
|
||||
setError(t('auth.error_session_incomplete'))
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const resolvedUser = username.trim() || encryptedPayloads?.username
|
||||
const key = await decryptWithLocalPin(pinLoginInput.trim(), resolvedUser)
|
||||
if (key) {
|
||||
onAuthenticated()
|
||||
} else {
|
||||
if (!key) {
|
||||
setError(t('auth.error_incorrect_pin'))
|
||||
return
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (!hasUnlockedLocalSession()) {
|
||||
setError(t('auth.error_session_incomplete'))
|
||||
return
|
||||
}
|
||||
setShowPinLogin(false)
|
||||
onAuthenticated()
|
||||
} catch {
|
||||
setError(t('auth.error_incorrect_pin'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
@@ -361,6 +392,24 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
||||
>
|
||||
{t('auth.use_recovery_instead')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
setShowPinLogin(false)
|
||||
setPinLoginInput('')
|
||||
setEncryptedPayloads(null)
|
||||
setError(null)
|
||||
await logoutUser()
|
||||
})()
|
||||
}}
|
||||
disabled={loading}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{t('auth.back')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -445,12 +494,21 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
||||
</div>
|
||||
|
||||
<div className="auth-form" style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||
{!passkeyHostOk && passkeyCompatibleUrl && (
|
||||
<div className="auth-error" role="alert">
|
||||
<p style={{ margin: '0 0 8px' }}>{t('auth.error_invalid_host')}</p>
|
||||
<a href={passkeyCompatibleUrl} className="btn secondary" style={{ display: 'inline-block', textDecoration: 'none' }}>
|
||||
{t('auth.use_localhost_link')}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Prominent Login button */}
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
onClick={() => handleLogin()}
|
||||
disabled={loading}
|
||||
disabled={loading || !passkeyHostOk}
|
||||
style={{ width: '100%', padding: '16px' }}
|
||||
>
|
||||
{loading
|
||||
@@ -583,7 +641,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
||||
<button
|
||||
type="submit"
|
||||
className="btn secondary"
|
||||
disabled={loading || !username.trim()}
|
||||
disabled={loading || !username.trim() || !passkeyHostOk}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{t('auth.register')}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Camera, X } from 'lucide-react'
|
||||
import {
|
||||
captureVideoFrame,
|
||||
preferNativeCameraPicker
|
||||
} from '../utils/captureVideoFrame.js'
|
||||
|
||||
interface LiveCameraCaptureProps {
|
||||
open: boolean
|
||||
busy?: boolean
|
||||
caption?: string
|
||||
onCaptionChange?: (value: string) => void
|
||||
onClose: () => void
|
||||
onCapture: (blob: Blob) => void
|
||||
}
|
||||
|
||||
type Phase = 'live' | 'preview' | 'native'
|
||||
|
||||
export default function LiveCameraCapture({
|
||||
open,
|
||||
busy = false,
|
||||
caption = '',
|
||||
onCaptionChange,
|
||||
onClose,
|
||||
onCapture
|
||||
}: LiveCameraCaptureProps) {
|
||||
const { t } = useTranslation()
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const streamRef = useRef<MediaStream | null>(null)
|
||||
const previewUrlRef = useRef<string | null>(null)
|
||||
|
||||
const [cameraError, setCameraError] = useState<string | null>(null)
|
||||
const [ready, setReady] = useState(false)
|
||||
const [capturing, setCapturing] = useState(false)
|
||||
const [phase, setPhase] = useState<Phase>(() => (preferNativeCameraPicker() ? 'native' : 'live'))
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||
const [previewBlob, setPreviewBlob] = useState<Blob | null>(null)
|
||||
const [streamGeneration, setStreamGeneration] = useState(0)
|
||||
|
||||
const clearPreview = useCallback(() => {
|
||||
if (previewUrlRef.current) {
|
||||
URL.revokeObjectURL(previewUrlRef.current)
|
||||
previewUrlRef.current = null
|
||||
}
|
||||
setPreviewUrl(null)
|
||||
setPreviewBlob(null)
|
||||
}, [])
|
||||
|
||||
const stopStream = useCallback(() => {
|
||||
for (const track of streamRef.current?.getTracks() ?? []) {
|
||||
track.stop()
|
||||
}
|
||||
streamRef.current = null
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = null
|
||||
}
|
||||
setReady(false)
|
||||
}, [])
|
||||
|
||||
const enterPreview = useCallback((blob: Blob) => {
|
||||
stopStream()
|
||||
clearPreview()
|
||||
const url = URL.createObjectURL(blob)
|
||||
previewUrlRef.current = url
|
||||
setPreviewBlob(blob)
|
||||
setPreviewUrl(url)
|
||||
setPhase('preview')
|
||||
}, [stopStream, clearPreview])
|
||||
|
||||
const resetToLive = useCallback(() => {
|
||||
clearPreview()
|
||||
setCameraError(null)
|
||||
setCapturing(false)
|
||||
if (preferNativeCameraPicker()) {
|
||||
setPhase('native')
|
||||
} else {
|
||||
setPhase('live')
|
||||
setStreamGeneration((n) => n + 1)
|
||||
}
|
||||
}, [clearPreview])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
stopStream()
|
||||
clearPreview()
|
||||
setCameraError(null)
|
||||
setCapturing(false)
|
||||
setPhase(preferNativeCameraPicker() ? 'native' : 'live')
|
||||
return
|
||||
}
|
||||
setPhase(preferNativeCameraPicker() ? 'native' : 'live')
|
||||
clearPreview()
|
||||
}, [open, stopStream, clearPreview])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || phase !== 'live') {
|
||||
stopStream()
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const start = async () => {
|
||||
setCameraError(null)
|
||||
setReady(false)
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
setCameraError(t('logs.live_photo_camera_unavailable'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
facingMode: { ideal: 'environment' },
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 }
|
||||
},
|
||||
audio: false
|
||||
})
|
||||
if (cancelled) {
|
||||
for (const track of stream.getTracks()) track.stop()
|
||||
return
|
||||
}
|
||||
streamRef.current = stream
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
|
||||
const markReady = () => {
|
||||
if (cancelled) return
|
||||
if (video.videoWidth > 0 && video.videoHeight > 0) {
|
||||
setReady(true)
|
||||
}
|
||||
}
|
||||
|
||||
video.onloadedmetadata = markReady
|
||||
video.srcObject = stream
|
||||
await video.play()
|
||||
markReady()
|
||||
} catch (err) {
|
||||
console.error('Camera access failed:', err)
|
||||
if (!cancelled) {
|
||||
setCameraError(t('logs.live_photo_camera_denied'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void start()
|
||||
return () => {
|
||||
cancelled = true
|
||||
stopStream()
|
||||
}
|
||||
}, [open, phase, streamGeneration, stopStream, t])
|
||||
|
||||
const handleCapture = async () => {
|
||||
const video = videoRef.current
|
||||
if (!video || !ready || busy || capturing) return
|
||||
|
||||
setCapturing(true)
|
||||
setCameraError(null)
|
||||
try {
|
||||
const blob = await captureVideoFrame(video)
|
||||
enterPreview(blob)
|
||||
} catch (err) {
|
||||
console.error('Live camera capture failed:', err)
|
||||
setCameraError(t('logs.live_photo_capture_failed'))
|
||||
} finally {
|
||||
setCapturing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNativeFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
if (!file || busy) return
|
||||
|
||||
setCameraError(null)
|
||||
try {
|
||||
enterPreview(file)
|
||||
} catch (err) {
|
||||
console.error('Live camera file pick failed:', err)
|
||||
setCameraError(t('logs.live_photo_capture_failed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
if (!previewBlob || busy) return
|
||||
onCapture(previewBlob)
|
||||
}
|
||||
|
||||
const handleRetake = () => {
|
||||
if (busy) return
|
||||
resetToLive()
|
||||
}
|
||||
|
||||
const openNativePicker = () => {
|
||||
if (busy) return
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const showPreview = phase === 'preview' && previewUrl
|
||||
|
||||
return (
|
||||
<div
|
||||
className="live-log-modal-backdrop live-camera-backdrop"
|
||||
onClick={(e) => { if (e.target === e.currentTarget && !busy) onClose() }}
|
||||
>
|
||||
<div className="live-log-modal live-camera-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="live-camera-header">
|
||||
<h3>{t('logs.live_photo_btn')}</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary live-camera-close"
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
aria-label={t('logs.confirm_no')}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="live-camera-file-input"
|
||||
onChange={(e) => void handleNativeFile(e)}
|
||||
/>
|
||||
|
||||
{cameraError && (
|
||||
<p className="live-log-modal-hint auth-error">{cameraError}</p>
|
||||
)}
|
||||
|
||||
{showPreview ? (
|
||||
<div className="live-camera-preview-wrap">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt=""
|
||||
className="live-camera-preview live-camera-preview-still"
|
||||
/>
|
||||
</div>
|
||||
) : phase === 'native' ? (
|
||||
<div className="live-camera-native-prompt">
|
||||
<p className="live-log-modal-hint">{t('logs.live_photo_native_hint')}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary live-camera-open-native"
|
||||
onClick={openNativePicker}
|
||||
disabled={busy}
|
||||
>
|
||||
<Camera size={18} />
|
||||
{t('logs.live_photo_open_camera_btn')}
|
||||
</button>
|
||||
</div>
|
||||
) : cameraError && !ready ? null : (
|
||||
<div className="live-camera-preview-wrap">
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="live-camera-preview"
|
||||
playsInline
|
||||
muted
|
||||
autoPlay
|
||||
/>
|
||||
{!ready && (
|
||||
<p className="live-camera-loading">{t('logs.live_photo_camera_starting')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onCaptionChange && (
|
||||
<div className="input-group live-camera-caption">
|
||||
<label>{t('logs.photo_caption_label')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
placeholder={t('logs.photo_caption_placeholder')}
|
||||
value={caption}
|
||||
onChange={(e) => onCaptionChange(e.target.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="live-log-modal-actions live-camera-actions">
|
||||
<button type="button" className="btn secondary" onClick={onClose} disabled={busy}>
|
||||
{t('logs.confirm_no')}
|
||||
</button>
|
||||
|
||||
{showPreview ? (
|
||||
<>
|
||||
<button type="button" className="btn secondary" onClick={handleRetake} disabled={busy}>
|
||||
{t('logs.live_photo_retake_btn')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary live-camera-shutter"
|
||||
onClick={handleSave}
|
||||
disabled={busy || !previewBlob}
|
||||
>
|
||||
<Camera size={18} />
|
||||
{busy ? t('logs.photo_processing') : t('logs.live_photo_save_btn')}
|
||||
</button>
|
||||
</>
|
||||
) : phase === 'native' ? null : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary live-camera-shutter"
|
||||
onClick={() => void handleCapture()}
|
||||
disabled={busy || capturing || !ready || !!cameraError}
|
||||
>
|
||||
<Camera size={18} />
|
||||
{capturing ? t('logs.photo_processing') : t('logs.live_photo_capture_btn')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,10 +9,11 @@ import { downloadCsv, shareCsv } from '../services/csvExport.js'
|
||||
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import LogEntryEditor from './LogEntryEditor.tsx'
|
||||
import LiveLogView from './LiveLogView.tsx'
|
||||
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import { getSkipperSignStatus, type SkipperSignStatus } from '../utils/signatures.js'
|
||||
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2 } from 'lucide-react'
|
||||
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2, Radio, List } from 'lucide-react'
|
||||
import {
|
||||
carryOverFromPreviousDay,
|
||||
compareTravelDaysChronological,
|
||||
@@ -36,6 +37,8 @@ interface LogEntriesListProps {
|
||||
highlightEntryId?: string | null
|
||||
}
|
||||
|
||||
type LogsViewMode = 'list' | 'live'
|
||||
|
||||
interface DecryptedEntryItem {
|
||||
id: string
|
||||
date: string
|
||||
@@ -75,6 +78,8 @@ export default function LogEntriesList({
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [viewMode, setViewMode] = useState<LogsViewMode>('list')
|
||||
const [returnToLiveAfterEditor, setReturnToLiveAfterEditor] = useState(false)
|
||||
const prevSelectedEntryIdRef = useRef<string | null | undefined>(undefined)
|
||||
|
||||
const loadEntries = useCallback(async () => {
|
||||
@@ -144,17 +149,19 @@ export default function LogEntriesList({
|
||||
}, [logbookId, readOnly, preloadedEntries])
|
||||
|
||||
useEffect(() => {
|
||||
if (viewMode === 'live') return
|
||||
loadEntries()
|
||||
}, [loadEntries])
|
||||
}, [loadEntries, viewMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (viewMode === 'live') return
|
||||
const prevSelectedEntryId = prevSelectedEntryIdRef.current
|
||||
prevSelectedEntryIdRef.current = selectedEntryId
|
||||
|
||||
if (prevSelectedEntryId !== undefined && prevSelectedEntryId !== null && selectedEntryId === null) {
|
||||
loadEntries()
|
||||
}
|
||||
}, [selectedEntryId, loadEntries])
|
||||
}, [selectedEntryId, loadEntries, viewMode])
|
||||
|
||||
const handleDownloadCsv = async () => {
|
||||
setExporting(true)
|
||||
@@ -350,7 +357,13 @@ export default function LogEntriesList({
|
||||
<LogEntryEditor
|
||||
entryId={selectedEntryId}
|
||||
logbookId={logbookId}
|
||||
onBack={() => setSelectedEntryId(null)}
|
||||
onBack={() => {
|
||||
setSelectedEntryId(null)
|
||||
if (returnToLiveAfterEditor) {
|
||||
setViewMode('live')
|
||||
setReturnToLiveAfterEditor(false)
|
||||
}
|
||||
}}
|
||||
readOnly={readOnly}
|
||||
preloadedEntry={preloadedEntries?.find(entry => (entry.payloadId || entry.id) === selectedEntryId)}
|
||||
preloadedPhotos={preloadedPhotos}
|
||||
@@ -359,6 +372,19 @@ export default function LogEntriesList({
|
||||
)
|
||||
}
|
||||
|
||||
if (viewMode === 'live' && !readOnly) {
|
||||
return (
|
||||
<LiveLogView
|
||||
logbookId={logbookId}
|
||||
onOpenEditor={(entryId) => {
|
||||
setReturnToLiveAfterEditor(true)
|
||||
setSelectedEntryId(entryId)
|
||||
}}
|
||||
onSwitchToList={() => setViewMode('list')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="tab-placeholder">
|
||||
@@ -381,6 +407,29 @@ export default function LogEntriesList({
|
||||
<h2>{t('logs.title')}</h2>
|
||||
</div>
|
||||
<div className="section-toolbar">
|
||||
{!readOnly && (
|
||||
<div className="logs-view-toggle" role="group" aria-label={t('logs.view_mode_label')}>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn secondary logs-view-toggle-btn ${viewMode === 'list' ? 'is-active' : ''}`}
|
||||
onClick={() => setViewMode('list')}
|
||||
title={t('logs.view_list')}
|
||||
>
|
||||
<List size={16} />
|
||||
<span className="hide-mobile">{t('logs.view_list')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn secondary logs-view-toggle-btn ${viewMode === 'live' ? 'is-active' : ''}`}
|
||||
onClick={() => setViewMode('live')}
|
||||
title={t('logs.live_mode')}
|
||||
>
|
||||
<Radio size={16} />
|
||||
<span className="hide-mobile">{t('logs.live_mode')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button className="btn secondary" onClick={handleDownloadCsv} disabled={loading || exporting || entries.length === 0} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.export_csv')}>
|
||||
<Download size={16} />
|
||||
<span className="hide-mobile">{exporting ? t('logs.exporting') : t('logs.export_csv')}</span>
|
||||
|
||||
@@ -25,7 +25,7 @@ import type { SignatureValue } from '../types/signatures.js'
|
||||
import { buildLogEntryPayload, sortLogEventsByTime, normalizeLogEvent, hasUnsavedEventDraft, currentLocalTimeHHMM, isValidTimeHHMM, type LogEventPayload } from '../utils/logEntryPayload.js'
|
||||
import EventTimeInput24h from './EventTimeInput24h.tsx'
|
||||
import CourseDialInput from './CourseDialInput.tsx'
|
||||
import { degreesToCardinal } from '../utils/courseAngle.js'
|
||||
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
|
||||
import { hashEntryForSigning } from '../utils/entryCanonicalHash.js'
|
||||
import { signLogEntry } from '../services/entrySigning.js'
|
||||
import { getLogbookAccess } from '../services/logbookAccess.js'
|
||||
@@ -900,7 +900,10 @@ export default function LogEntryEditor({
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fetchOpenWeatherCurrent({ q: locationQuery })
|
||||
const data = await fetchOpenWeatherCurrent(
|
||||
{ q: locationQuery },
|
||||
{ analyticsSource: 'entry_editor_gps_lookup' }
|
||||
)
|
||||
const coord = data.coord as { lat?: number; lon?: number } | undefined
|
||||
if (coord?.lat !== undefined && coord?.lon !== undefined) {
|
||||
setEvGpsLat(Number(coord.lat).toFixed(6))
|
||||
@@ -955,7 +958,8 @@ export default function LogEntryEditor({
|
||||
const data = await fetchOpenWeatherCurrent(
|
||||
hasGps
|
||||
? { lat: evGpsLat, lon: evGpsLng }
|
||||
: { q: fallbackLocation }
|
||||
: { q: fallbackLocation },
|
||||
{ analyticsSource: 'entry_editor' }
|
||||
)
|
||||
|
||||
const coord = data.coord as { lat?: number; lon?: number } | undefined
|
||||
@@ -965,38 +969,11 @@ export default function LogEntryEditor({
|
||||
setEvGpsLng(Number(coord.lon).toFixed(6))
|
||||
}
|
||||
|
||||
const wind = data.wind as { speed?: number; deg?: number } | undefined
|
||||
const main = data.main as { pressure?: number } | undefined
|
||||
|
||||
// Convert wind speed m/s to Beaufort scale
|
||||
const mps = wind?.speed || 0
|
||||
let bft = 0
|
||||
if (mps < 0.3) bft = 0
|
||||
else if (mps < 1.6) bft = 1
|
||||
else if (mps < 3.4) bft = 2
|
||||
else if (mps < 5.5) bft = 3
|
||||
else if (mps < 8.0) bft = 4
|
||||
else if (mps < 10.8) bft = 5
|
||||
else if (mps < 13.9) bft = 6
|
||||
else if (mps < 17.2) bft = 7
|
||||
else if (mps < 20.8) bft = 8
|
||||
else if (mps < 24.5) bft = 9
|
||||
else if (mps < 28.5) bft = 10
|
||||
else if (mps < 32.7) bft = 11
|
||||
else bft = 12
|
||||
|
||||
setEvWindStrength(`${bft} Bft (${mps.toFixed(1)} m/s)`)
|
||||
setEvWindPressure(String(main?.pressure || ''))
|
||||
|
||||
// Calculate wind compass direction sector
|
||||
if (wind?.deg !== undefined) {
|
||||
setEvWindDirection(degreesToCardinal(wind.deg))
|
||||
}
|
||||
|
||||
if (data.weather && Array.isArray(data.weather) && data.weather[0]) {
|
||||
const first = data.weather[0] as { icon?: string }
|
||||
if (first.icon) setEvWeatherIcon(first.icon)
|
||||
}
|
||||
const parsed = parseOwmCurrentWeather(data)
|
||||
setEvWindStrength(parsed.windStrength)
|
||||
setEvWindPressure(parsed.windPressure)
|
||||
if (parsed.windDirection) setEvWindDirection(parsed.windDirection)
|
||||
if (parsed.weatherIcon) setEvWeatherIcon(parsed.weatherIcon)
|
||||
|
||||
showAlert(t('settings.weather_success'))
|
||||
} catch (err) {
|
||||
|
||||
@@ -101,6 +101,12 @@ export default function NmeaImportWizard({
|
||||
t
|
||||
}).candidates
|
||||
setSelectedIds(new Set(generated.map((c) => c.id)))
|
||||
trackPlausibleEvent(PlausibleEvents.NMEA_UPLOADED, {
|
||||
duplicate: alreadyImported,
|
||||
lines: result.stats.parsedLines,
|
||||
candidates: generated.length,
|
||||
has_position: !result.warnings.includes('no_position')
|
||||
})
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('logs.nmea_error_parse'))
|
||||
}
|
||||
@@ -154,7 +160,7 @@ export default function NmeaImportWizard({
|
||||
}
|
||||
trackPlausibleEvent(PlausibleEvents.NMEA_IMPORTED, {
|
||||
mode,
|
||||
candidates: picked.length,
|
||||
events: picked.length,
|
||||
track: importTrack && (waypoints?.length ?? 0) > 0
|
||||
})
|
||||
setStep('archive')
|
||||
|
||||
@@ -3,9 +3,9 @@ import { useTranslation } from 'react-i18next'
|
||||
import { db } from '../services/db.js'
|
||||
import { getActiveMasterKey } from '../services/auth.js'
|
||||
import { getLogbookKey } from '../services/logbookKeys.js'
|
||||
import { encryptJson, decryptJson } from '../services/crypto.js'
|
||||
import { syncLogbook } from '../services/sync.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { decryptJson } from '../services/crypto.js'
|
||||
import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.js'
|
||||
import { fileToCompressedJpegDataUrl } from '../utils/imageCompress.js'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import { Camera, Trash2 } from 'lucide-react'
|
||||
@@ -90,109 +90,30 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
|
||||
setUploading(true)
|
||||
setError(null)
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
const img = new Image()
|
||||
img.onload = async () => {
|
||||
try {
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) throw new Error('Could not get canvas context')
|
||||
|
||||
let width = img.width
|
||||
let height = img.height
|
||||
const MAX_WIDTH = 1280
|
||||
const MAX_HEIGHT = 720
|
||||
|
||||
// Calculate resizing conserving aspect ratio
|
||||
if (width > MAX_WIDTH || height > MAX_HEIGHT) {
|
||||
const ratio = Math.min(MAX_WIDTH / width, MAX_HEIGHT / height)
|
||||
width = Math.round(width * ratio)
|
||||
height = Math.round(height * ratio)
|
||||
}
|
||||
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
ctx.drawImage(img, 0, 0, width, height)
|
||||
|
||||
// Compress to JPEG, 70% quality
|
||||
const compressedBase64 = canvas.toDataURL('image/jpeg', 0.7)
|
||||
|
||||
// Encrypt
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
|
||||
const photoId = window.crypto.randomUUID()
|
||||
const photoPayload = {
|
||||
image: compressedBase64,
|
||||
caption: caption.trim()
|
||||
}
|
||||
|
||||
const encrypted = await encryptJson(photoPayload, masterKey)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// Store locally
|
||||
await db.photos.put({
|
||||
payloadId: photoId,
|
||||
entryId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
caption: '', // stored encrypted inside payload
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
// Queue for background sync
|
||||
await db.syncQueue.put({
|
||||
action: 'create',
|
||||
type: 'photo',
|
||||
payloadId: photoId,
|
||||
logbookId,
|
||||
data: JSON.stringify({
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
entryId
|
||||
}),
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
setCaption('')
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: 'logbook' })
|
||||
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
} catch (err: any) {
|
||||
console.error('Failed to process image:', err)
|
||||
setError(err.message || 'Failed to process image')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
img.src = event.target?.result as string
|
||||
try {
|
||||
const compressedBase64 = await fileToCompressedJpegDataUrl(file)
|
||||
await saveEntryPhoto({
|
||||
logbookId,
|
||||
entryId,
|
||||
imageDataUrl: compressedBase64,
|
||||
caption: caption.trim(),
|
||||
analyticsContext: 'logbook'
|
||||
})
|
||||
setCaption('')
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to process image:', err)
|
||||
setError(err instanceof Error ? err.message : 'Failed to process image')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
const handleDelete = async (photoId: string) => {
|
||||
if (await showConfirm(t('logs.photo_delete_confirm'), t('logs.photos_title'), t('logs.confirm_yes'), t('logs.confirm_no'))) {
|
||||
try {
|
||||
const now = new Date().toISOString()
|
||||
|
||||
await db.photos.delete(photoId)
|
||||
|
||||
await db.syncQueue.put({
|
||||
action: 'delete',
|
||||
type: 'photo',
|
||||
payloadId: photoId,
|
||||
logbookId,
|
||||
data: '',
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
} catch (err: any) {
|
||||
await deleteEntryPhoto(logbookId, photoId)
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to delete photo:', err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { Settings as SettingsIcon, Check, Users, Trash2, Copy, Link as LinkIcon } from 'lucide-react'
|
||||
import { ensureLogbookKey } from '../services/logbookKeys.js'
|
||||
import LogbookBackupPanel from './LogbookBackupPanel.tsx'
|
||||
import LinkQrCode from './LinkQrCode.tsx'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { apiFetch } from '../services/api.js'
|
||||
@@ -314,23 +315,27 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
||||
</div>
|
||||
|
||||
{shareEnabled && shareLink && (
|
||||
<div className="input-group mb-4 copy-link-row">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={shareLink}
|
||||
className="input-text font-mono text-xs"
|
||||
style={{ flex: 1, padding: '10px' }}
|
||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={handleCopyShareLink}
|
||||
style={{ width: 'auto', padding: '10px' }}
|
||||
>
|
||||
{shareCopied ? <Check size={16} /> : <Copy size={16} />}
|
||||
</button>
|
||||
<div className="link-with-qr mb-4">
|
||||
<div className="input-group copy-link-row">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={shareLink}
|
||||
className="input-text font-mono text-xs"
|
||||
style={{ flex: 1, padding: '10px' }}
|
||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={handleCopyShareLink}
|
||||
style={{ width: 'auto', padding: '10px' }}
|
||||
title={t('settings.share_copy_btn')}
|
||||
>
|
||||
{shareCopied ? <Check size={16} /> : <Copy size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
<LinkQrCode value={shareLink} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -367,23 +372,27 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
||||
</div>
|
||||
|
||||
{inviteLink && (
|
||||
<div className="input-group mb-6 copy-link-row">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={inviteLink}
|
||||
className="input-text font-mono text-xs"
|
||||
style={{ flex: 1, padding: '10px' }}
|
||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={handleCopyInvite}
|
||||
style={{ width: 'auto', padding: '10px' }}
|
||||
>
|
||||
{inviteCopied ? <Check size={16} /> : <Copy size={16} />}
|
||||
</button>
|
||||
<div className="link-with-qr mb-6">
|
||||
<div className="input-group copy-link-row">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={inviteLink}
|
||||
className="input-text font-mono text-xs"
|
||||
style={{ flex: 1, padding: '10px' }}
|
||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={handleCopyInvite}
|
||||
style={{ width: 'auto', padding: '10px' }}
|
||||
title={t('settings.share_copy_btn')}
|
||||
>
|
||||
{inviteCopied ? <Check size={16} /> : <Copy size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
<LinkQrCode value={inviteLink} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -14,6 +14,11 @@ import {
|
||||
} from '../services/statsAggregation.js'
|
||||
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
|
||||
import { formatFuelPerMotorHour } from '../utils/fuelStats.js'
|
||||
import {
|
||||
loadLogbookEventSeries,
|
||||
type EventSeriesPoint,
|
||||
type EventSeriesSummary
|
||||
} from '../services/eventSeriesAggregation.js'
|
||||
|
||||
interface StatsDashboardProps {
|
||||
logbookId: string
|
||||
@@ -217,7 +222,62 @@ function PropulsionBreakdown({ totals }: { totals: StatsTotals }) {
|
||||
)
|
||||
}
|
||||
|
||||
function LogbookScopeView({ summary }: { summary: LogbookStatsSummary }) {
|
||||
function EventSeriesList({ title, points, emptyLabel }: { title: string; points: EventSeriesPoint[]; emptyLabel: string }) {
|
||||
if (points.length === 0) {
|
||||
return (
|
||||
<div className="stats-event-series-block">
|
||||
<h4 className="stats-section-subtitle">{title}</h4>
|
||||
<p className="stats-section-sub">{emptyLabel}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stats-event-series-block">
|
||||
<h4 className="stats-section-subtitle">{title}</h4>
|
||||
<ul className="stats-event-series-list">
|
||||
{points.map((point, idx) => (
|
||||
<li key={`${point.entryId}-${point.time}-${idx}`} className="stats-event-series-item">
|
||||
<span className="stats-event-series-when">
|
||||
{new Date(point.date).toLocaleDateString(undefined, { day: '2-digit', month: '2-digit' })}
|
||||
{' · '}
|
||||
{point.time}
|
||||
</span>
|
||||
<span className="stats-event-series-value">{point.summary}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EventSeriesPanel({ series }: { series: EventSeriesSummary }) {
|
||||
const { t } = useTranslation()
|
||||
const motorPoints = series.motor.map((point) => ({
|
||||
...point,
|
||||
summary: point.summary === 'start'
|
||||
? t('logs.live_motor_start')
|
||||
: t('logs.live_motor_stop')
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="member-editor-card glass mt-6">
|
||||
<h3 className="stats-section-title">{t('stats.event_series_title')}</h3>
|
||||
<p className="stats-section-sub">{t('stats.event_series_hint')}</p>
|
||||
<EventSeriesList title={t('stats.event_series_pressure')} points={series.pressure} emptyLabel={t('stats.event_series_empty')} />
|
||||
<EventSeriesList title={t('stats.event_series_wind')} points={series.wind} emptyLabel={t('stats.event_series_empty')} />
|
||||
<EventSeriesList title={t('stats.event_series_motor')} points={motorPoints} emptyLabel={t('stats.event_series_empty')} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LogbookScopeView({
|
||||
summary,
|
||||
eventSeries
|
||||
}: {
|
||||
summary: LogbookStatsSummary
|
||||
eventSeries: EventSeriesSummary | null
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { travelDays, routePorts, trackSegments, totals } = summary
|
||||
|
||||
@@ -313,6 +373,8 @@ function LogbookScopeView({ summary }: { summary: LogbookStatsSummary }) {
|
||||
<h3 className="stats-section-title">{t('stats.propulsion_title')}</h3>
|
||||
<PropulsionBreakdown totals={totals} />
|
||||
</div>
|
||||
|
||||
{eventSeries && <EventSeriesPanel series={eventSeries} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -323,18 +385,21 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [logbookStats, setLogbookStats] = useState<LogbookStatsSummary | null>(null)
|
||||
const [eventSeries, setEventSeries] = useState<EventSeriesSummary | null>(null)
|
||||
const [accountStats, setAccountStats] = useState<Awaited<ReturnType<typeof loadAccountStats>> | null>(null)
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [lb, acc] = await Promise.all([
|
||||
const [lb, acc, series] = await Promise.all([
|
||||
loadLogbookStats(logbookId, logbookTitle, true),
|
||||
loadAccountStats(false)
|
||||
loadAccountStats(false),
|
||||
loadLogbookEventSeries(logbookId)
|
||||
])
|
||||
setLogbookStats(lb)
|
||||
setAccountStats(acc)
|
||||
setEventSeries(series)
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to load statistics:', err)
|
||||
setError(err instanceof Error ? err.message : 'Failed to load statistics.')
|
||||
@@ -397,7 +462,7 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
|
||||
<p>{t('stats.loading')}</p>
|
||||
</div>
|
||||
) : scope === 'logbook' && logbookStats ? (
|
||||
<LogbookScopeView summary={logbookStats} />
|
||||
<LogbookScopeView summary={logbookStats} eventSeries={eventSeries} />
|
||||
) : scope === 'account' && accountStats ? (
|
||||
<>
|
||||
<TotalsGrid totals={accountStats.totals} />
|
||||
|
||||
@@ -68,7 +68,12 @@
|
||||
"enter_pin_placeholder": "Indtast din pinkode...",
|
||||
"decrypt_with_pin": "Afkodning",
|
||||
"use_recovery_instead": "Brug genoprettelsesnøgler i stedet",
|
||||
"error_incorrect_pin": "Forkert PIN-kode. Dekryptering mislykkedes."
|
||||
"error_incorrect_pin": "Forkert PIN-kode. Dekryptering mislykkedes.",
|
||||
"error_invalid_host": "Passkeys virker ikke via 127.0.0.1. Åbn appen via localhost.",
|
||||
"use_localhost_link": "Skift til localhost",
|
||||
"error_passkey_cancelled": "Passkey-login blev annulleret eller udløb. Prøv igen.",
|
||||
"error_invalid_rp_id": "Passkey-domæne matcher ikke (RP ID). Brug http://localhost:5173 med RP_ID=localhost i .env til lokal udvikling.",
|
||||
"error_session_incomplete": "Login ufuldstændig. Log ind med passkey igen."
|
||||
},
|
||||
"pwa": {
|
||||
"title": "Installer app",
|
||||
@@ -197,6 +202,94 @@
|
||||
"saving": "Vil blive reddet...",
|
||||
"saved": "Logbogsside gemt med succes!",
|
||||
"loading": "Dagbogen er ved at blive indlæst.",
|
||||
"view_mode_label": "Visning",
|
||||
"view_list": "Liste",
|
||||
"live_mode": "Live",
|
||||
"live_title": "Live-journal",
|
||||
"live_loading": "Live-journal indlæses...",
|
||||
"live_retry": "Prøv igen",
|
||||
"live_load_error": "Live-journal kunne ikke indlæses.",
|
||||
"live_action_error": "Indtastning kunne ikke gemmes.",
|
||||
"live_open_editor": "Fuld editor",
|
||||
"live_actions_label": "Hurtighandlinger",
|
||||
"live_stream_label": "Hændelseslog",
|
||||
"live_stream_title": "Journal",
|
||||
"live_no_events": "Ingen indtastninger endnu — tryk på en handling.",
|
||||
"live_motor_start": "Motor Start",
|
||||
"live_motor_stop": "Motor Stop",
|
||||
"live_cast_off": "Afsejling",
|
||||
"live_moor": "Anløb",
|
||||
"live_sails_btn": "Sejl",
|
||||
"live_sails_pick": "Vælg sejl",
|
||||
"live_sails_pick_hint": "Tryk på flere sejl (tryk igen for at fravælge), og indtast derefter.",
|
||||
"live_sails_selected": "Valgt: {{sails}}",
|
||||
"live_sails_confirm": "Indtast",
|
||||
"live_sails_confirm_count": "Indtast ({{count}})",
|
||||
"live_sails": "Sejl: {{sails}}",
|
||||
"live_fix": "Fix",
|
||||
"live_fix_coords": "Fix {{lat}}, {{lng}}",
|
||||
"live_fix_manual_hint": "GPS ikke tilgængelig. Indtast bredde- og længdegrad manuelt, eller prøv igen med GPS-knappen.",
|
||||
"live_fix_gps_loading": "Henter GPS-position…",
|
||||
"live_fix_invalid": "Indtast gyldige koordinater (bredde −90…90, længde −180…180).",
|
||||
"live_fix_lat_placeholder": "Bredde (Lat)",
|
||||
"live_fix_lng_placeholder": "Længde (Lng)",
|
||||
"live_photo_btn": "Foto (kamera)",
|
||||
"live_photo_capture_btn": "Tag billede",
|
||||
"live_photo_save_btn": "Gem",
|
||||
"live_photo_retake_btn": "Tag igen",
|
||||
"live_photo_capture_failed": "Optagelse mislykkedes. Prøv igen.",
|
||||
"live_photo_open_camera_btn": "Åbn kamera",
|
||||
"live_photo_native_hint": "Tag et foto med enhedens kamera og gem det her bagefter.",
|
||||
"live_photo_camera_starting": "Starter kamera…",
|
||||
"live_photo_camera_denied": "Kameraadgang nægtet eller utilgængelig.",
|
||||
"live_photo_camera_unavailable": "Kamera understøttes ikke i denne browser.",
|
||||
"live_photo_error": "Foto kunne ikke gemmes.",
|
||||
"live_photo_entry": "Foto: {{caption}}",
|
||||
"live_photo_entry_plain": "Foto taget",
|
||||
"live_undo_photo_hint": "Foto gemt",
|
||||
"live_comment_btn": "Kommentar",
|
||||
"live_comment_placeholder": "Indtast tekst…",
|
||||
"live_comment_confirm": "Indtast",
|
||||
"live_gps_error": "GPS-position kunne ikke bestemmes.",
|
||||
"live_event_generic": "Hændelse",
|
||||
"live_weather_btn": "Vejr",
|
||||
"live_weather_owm_btn": "Hent OpenWeatherMap-vejr",
|
||||
"live_weather_owm_loading": "Henter vejr…",
|
||||
"live_weather_fix_required": "Log først en GPS-fix (Fix-knap) for at hente OpenWeatherMap-vejr. Positionen må højst være 6 timer gammel.",
|
||||
"live_weather_fix_stale": "Den seneste GPS-fix er ældre end 6 timer. Log en ny fix, før du henter vejr.",
|
||||
"live_wind_btn": "Vind",
|
||||
"live_temp_btn": "T °C",
|
||||
"live_pressure_btn": "Lufttryk",
|
||||
"live_precip_btn": "Nedbør",
|
||||
"live_sea_state_btn": "Søgang",
|
||||
"live_course_btn": "Kurs",
|
||||
"live_fuel_btn": "Diesel",
|
||||
"live_water_btn": "Vand",
|
||||
"live_wind_entry": "Vind {{value}}",
|
||||
"live_temp_entry": "Temperatur {{temp}} °C",
|
||||
"live_pressure_entry": "Lufttryk {{value}} hPa",
|
||||
"live_precip_entry": "Nedbør {{value}}",
|
||||
"live_sea_state_entry": "Søgang {{value}}",
|
||||
"live_course_entry": "Kurs {{course}}",
|
||||
"live_fuel_entry": "Diesel +{{liters}} L",
|
||||
"live_water_entry": "Vand +{{liters}} L",
|
||||
"live_auto_position": "Auto-position",
|
||||
"live_undo_hint": "Indtastning gemt",
|
||||
"live_undo_btn": "Fortryd",
|
||||
"live_pressure_placeholder": "f.eks. 1013",
|
||||
"live_temp_placeholder": "f.eks. 18",
|
||||
"live_precip_placeholder": "f.eks. let regn",
|
||||
"live_sea_state_placeholder": "f.eks. 3",
|
||||
"live_course_placeholder": "f.eks. 245",
|
||||
"live_fuel_placeholder": "Optankede liter",
|
||||
"live_water_placeholder": "Optankede liter",
|
||||
"live_sog_btn": "SOG",
|
||||
"live_stw_btn": "STW",
|
||||
"live_sog_entry": "SOG {{speed}} kn",
|
||||
"live_stw_entry": "STW {{speed}} kn",
|
||||
"live_sog_placeholder": "f.eks. 5,2",
|
||||
"live_stw_placeholder": "f.eks. 4,8",
|
||||
"live_sog_hint": "Fart over grund (kn) — GPS-værdi forudfyldes, hvis tilgængelig.",
|
||||
"delete_entry": "Slet tag",
|
||||
"delete_confirm": "Er du sikker på, at du vil slette denne rejsedag permanent?",
|
||||
"carry_over_tanks_title": "Overføre data fra den foregående dag?",
|
||||
@@ -545,6 +638,8 @@
|
||||
"share_enable": "Aktivér offentligt link",
|
||||
"share_copied": "Link kopieret!",
|
||||
"share_copy_btn": "Kopier link",
|
||||
"link_qr_hint": "Scan QR-koden med din telefon",
|
||||
"link_qr_alt": "QR-kode til linket",
|
||||
"danger_zone_title": "Farezone",
|
||||
"danger_zone_desc": "Når du sletter din konto, slettes alle dine Passkey'er, logbøger, skibsdata, besætningsprofiler, rejseindlæg og E2E-nøgler uigenkaldeligt. Denne handling kan ikke fortrydes.",
|
||||
"delete_account_btn": "Slet konto uigenkaldeligt",
|
||||
@@ -713,7 +808,13 @@
|
||||
"unit_l": "L",
|
||||
"day_label": "Dag {{day}}",
|
||||
"account_logbooks": "Et overblik over logbøger",
|
||||
"col_logbook": "Logbog"
|
||||
"col_logbook": "Logbog",
|
||||
"event_series_title": "Hændelsesforløb",
|
||||
"event_series_hint": "Kronologiske værdier fra hændelsesloggen.",
|
||||
"event_series_pressure": "Lufttryk",
|
||||
"event_series_wind": "Vind",
|
||||
"event_series_motor": "Motor",
|
||||
"event_series_empty": "Ingen indtastninger endnu."
|
||||
},
|
||||
"tour": {
|
||||
"skip": "Spring turen over",
|
||||
|
||||
@@ -68,7 +68,12 @@
|
||||
"enter_pin_placeholder": "Gib deine PIN ein...",
|
||||
"decrypt_with_pin": "Entschlüsseln",
|
||||
"use_recovery_instead": "Stattdessen Wiederherstellungsschlüssel verwenden",
|
||||
"error_incorrect_pin": "Falsche PIN. Entschlüsselung fehlgeschlagen."
|
||||
"error_incorrect_pin": "Falsche PIN. Entschlüsselung fehlgeschlagen.",
|
||||
"error_invalid_host": "Passkeys funktionieren nicht über 127.0.0.1. Bitte die App über localhost öffnen.",
|
||||
"use_localhost_link": "Zu localhost wechseln",
|
||||
"error_passkey_cancelled": "Passkey-Anmeldung abgebrochen oder abgelaufen. Bitte erneut versuchen.",
|
||||
"error_invalid_rp_id": "Passkey-Domain passt nicht (RP ID). Lokal nur http://localhost:5173 mit RP_ID=localhost in .env verwenden.",
|
||||
"error_session_incomplete": "Anmeldung unvollständig. Bitte erneut mit Passkey anmelden."
|
||||
},
|
||||
"pwa": {
|
||||
"title": "App installieren",
|
||||
@@ -197,6 +202,94 @@
|
||||
"saving": "Wird gespeichert...",
|
||||
"saved": "Logbuchseite erfolgreich gespeichert!",
|
||||
"loading": "Journal wird geladen...",
|
||||
"view_mode_label": "Ansicht",
|
||||
"view_list": "Liste",
|
||||
"live_mode": "Live",
|
||||
"live_title": "Live-Journal",
|
||||
"live_loading": "Live-Journal wird geladen...",
|
||||
"live_retry": "Erneut versuchen",
|
||||
"live_load_error": "Live-Journal konnte nicht geladen werden.",
|
||||
"live_action_error": "Eintrag konnte nicht gespeichert werden.",
|
||||
"live_open_editor": "Vollständiger Editor",
|
||||
"live_actions_label": "Schnellaktionen",
|
||||
"live_stream_label": "Ereignisprotokoll",
|
||||
"live_stream_title": "Journal",
|
||||
"live_no_events": "Noch keine Einträge — tippe auf eine Aktion.",
|
||||
"live_motor_start": "Motor Start",
|
||||
"live_motor_stop": "Motor Stop",
|
||||
"live_cast_off": "Ablegen",
|
||||
"live_moor": "Anlegen",
|
||||
"live_sails_btn": "Segel",
|
||||
"live_sails_pick": "Segel auswählen",
|
||||
"live_sails_pick_hint": "Mehrere Segel antippen (erneut antippen zum Abwählen), dann Eintragen.",
|
||||
"live_sails_selected": "Auswahl: {{sails}}",
|
||||
"live_sails_confirm": "Eintragen",
|
||||
"live_sails_confirm_count": "Eintragen ({{count}})",
|
||||
"live_sails": "Segel: {{sails}}",
|
||||
"live_fix": "Fix",
|
||||
"live_fix_coords": "Fix {{lat}}, {{lng}}",
|
||||
"live_fix_manual_hint": "GPS nicht verfügbar. Breiten- und Längengrad manuell eingeben oder erneut per GPS-Knopf versuchen.",
|
||||
"live_fix_gps_loading": "GPS-Position wird ermittelt…",
|
||||
"live_fix_invalid": "Bitte gültige Koordinaten eingeben (Breite −90…90, Länge −180…180).",
|
||||
"live_fix_lat_placeholder": "Breite (Lat)",
|
||||
"live_fix_lng_placeholder": "Länge (Lng)",
|
||||
"live_photo_btn": "Foto (Kamera)",
|
||||
"live_photo_capture_btn": "Aufnehmen",
|
||||
"live_photo_save_btn": "Speichern",
|
||||
"live_photo_retake_btn": "Neu aufnehmen",
|
||||
"live_photo_capture_failed": "Aufnahme fehlgeschlagen. Bitte erneut versuchen.",
|
||||
"live_photo_open_camera_btn": "Kamera öffnen",
|
||||
"live_photo_native_hint": "Foto mit der Gerätekamera aufnehmen und anschließend hier speichern.",
|
||||
"live_photo_camera_starting": "Kamera wird gestartet…",
|
||||
"live_photo_camera_denied": "Kamerazugriff verweigert oder nicht verfügbar.",
|
||||
"live_photo_camera_unavailable": "Kamera wird von diesem Browser nicht unterstützt.",
|
||||
"live_photo_error": "Foto konnte nicht gespeichert werden.",
|
||||
"live_photo_entry": "Foto: {{caption}}",
|
||||
"live_photo_entry_plain": "Foto aufgenommen",
|
||||
"live_undo_photo_hint": "Foto gespeichert",
|
||||
"live_comment_btn": "Kommentar",
|
||||
"live_comment_placeholder": "Freitext eingeben…",
|
||||
"live_comment_confirm": "Eintragen",
|
||||
"live_gps_error": "GPS-Position konnte nicht ermittelt werden.",
|
||||
"live_event_generic": "Ereignis",
|
||||
"live_weather_btn": "Wetter",
|
||||
"live_weather_owm_btn": "OpenWeatherMap Wetter abrufen",
|
||||
"live_weather_owm_loading": "Wetter wird geladen…",
|
||||
"live_weather_fix_required": "Für Wetter von OpenWeatherMap zuerst einen GPS-Fix eintragen (Schaltfläche „Fix“). Die Position darf höchstens 6 Stunden alt sein.",
|
||||
"live_weather_fix_stale": "Der letzte GPS-Fix ist älter als 6 Stunden. Bitte erneut einen Fix loggen, bevor du Wetter abrufst.",
|
||||
"live_wind_btn": "Wind",
|
||||
"live_temp_btn": "T °C",
|
||||
"live_pressure_btn": "Luftdruck",
|
||||
"live_precip_btn": "Niederschlag",
|
||||
"live_sea_state_btn": "Seegang",
|
||||
"live_course_btn": "Kurs",
|
||||
"live_fuel_btn": "Diesel",
|
||||
"live_water_btn": "Wasser",
|
||||
"live_wind_entry": "Wind {{value}}",
|
||||
"live_temp_entry": "Temperatur {{temp}} °C",
|
||||
"live_pressure_entry": "Luftdruck {{value}} hPa",
|
||||
"live_precip_entry": "Niederschlag {{value}}",
|
||||
"live_sea_state_entry": "Seegang {{value}}",
|
||||
"live_course_entry": "Kurs {{course}}",
|
||||
"live_fuel_entry": "Diesel +{{liters}} L",
|
||||
"live_water_entry": "Wasser +{{liters}} L",
|
||||
"live_auto_position": "Auto-Position",
|
||||
"live_undo_hint": "Eintrag gespeichert",
|
||||
"live_undo_btn": "Rückgängig",
|
||||
"live_pressure_placeholder": "z. B. 1013",
|
||||
"live_temp_placeholder": "z. B. 18",
|
||||
"live_precip_placeholder": "z. B. leichter Regen",
|
||||
"live_sea_state_placeholder": "z. B. 3",
|
||||
"live_course_placeholder": "z. B. 245",
|
||||
"live_fuel_placeholder": "Nachgefüllte Liter",
|
||||
"live_water_placeholder": "Nachgefüllte Liter",
|
||||
"live_sog_btn": "SOG",
|
||||
"live_stw_btn": "STW",
|
||||
"live_sog_entry": "SOG {{speed}} kn",
|
||||
"live_stw_entry": "STW {{speed}} kn",
|
||||
"live_sog_placeholder": "z. B. 5,2",
|
||||
"live_stw_placeholder": "z. B. 4,8",
|
||||
"live_sog_hint": "Fahrt über Grund (kn) — GPS-Wert wird vorgefüllt, wenn verfügbar.",
|
||||
"delete_entry": "Tag löschen",
|
||||
"delete_confirm": "Bist du sicher, dass du diesen Reisetag unwiderruflich löschen möchtest?",
|
||||
"carry_over_tanks_title": "Daten vom Vortag übernehmen?",
|
||||
@@ -545,6 +638,8 @@
|
||||
"share_enable": "Öffentlichen Link aktivieren",
|
||||
"share_copied": "Link kopiert!",
|
||||
"share_copy_btn": "Link kopieren",
|
||||
"link_qr_hint": "QR-Code zum Scannen mit dem Smartphone",
|
||||
"link_qr_alt": "QR-Code für den Link",
|
||||
"danger_zone_title": "Gefahrenzone",
|
||||
"danger_zone_desc": "Durch das Löschen deines Kontos werden alle deine Passkeys, Logbücher, Schiffsdaten, Crew-Profile, Reiseeinträge und E2E-Schlüssel unwiderruflich gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"delete_account_btn": "Konto unwiderruflich löschen",
|
||||
@@ -713,7 +808,13 @@
|
||||
"unit_l": "L",
|
||||
"day_label": "Tag {{day}}",
|
||||
"account_logbooks": "Logbücher im Überblick",
|
||||
"col_logbook": "Logbuch"
|
||||
"col_logbook": "Logbuch",
|
||||
"event_series_title": "Ereignis-Verläufe",
|
||||
"event_series_hint": "Chronologische Werte aus dem Ereignisprotokoll.",
|
||||
"event_series_pressure": "Luftdruck",
|
||||
"event_series_wind": "Wind",
|
||||
"event_series_motor": "Motor",
|
||||
"event_series_empty": "Keine Einträge vorhanden."
|
||||
},
|
||||
"tour": {
|
||||
"skip": "Tour überspringen",
|
||||
|
||||
@@ -68,7 +68,12 @@
|
||||
"enter_pin_placeholder": "Enter your PIN...",
|
||||
"decrypt_with_pin": "Decrypt",
|
||||
"use_recovery_instead": "Use recovery phrase instead",
|
||||
"error_incorrect_pin": "Incorrect PIN. Decryption failed."
|
||||
"error_incorrect_pin": "Incorrect PIN. Decryption failed.",
|
||||
"error_invalid_host": "Passkeys do not work on 127.0.0.1. Please open the app via localhost.",
|
||||
"use_localhost_link": "Switch to localhost",
|
||||
"error_passkey_cancelled": "Passkey sign-in was cancelled or timed out. Please try again.",
|
||||
"error_invalid_rp_id": "Passkey domain mismatch (RP ID). For local dev use http://localhost:5173 with RP_ID=localhost in .env.",
|
||||
"error_session_incomplete": "Sign-in incomplete. Please sign in with your passkey again."
|
||||
},
|
||||
"pwa": {
|
||||
"title": "Install app",
|
||||
@@ -197,6 +202,94 @@
|
||||
"saving": "Saving...",
|
||||
"saved": "Logbook page saved successfully!",
|
||||
"loading": "Loading journal...",
|
||||
"view_mode_label": "View",
|
||||
"view_list": "List",
|
||||
"live_mode": "Live",
|
||||
"live_title": "Live Journal",
|
||||
"live_loading": "Loading live journal...",
|
||||
"live_retry": "Try again",
|
||||
"live_load_error": "Could not load live journal.",
|
||||
"live_action_error": "Could not save entry.",
|
||||
"live_open_editor": "Full editor",
|
||||
"live_actions_label": "Quick actions",
|
||||
"live_stream_label": "Event log",
|
||||
"live_stream_title": "Journal",
|
||||
"live_no_events": "No entries yet — tap an action.",
|
||||
"live_motor_start": "Engine Start",
|
||||
"live_motor_stop": "Engine Stop",
|
||||
"live_cast_off": "Cast off",
|
||||
"live_moor": "Moor",
|
||||
"live_sails_btn": "Sails",
|
||||
"live_sails_pick": "Select sails",
|
||||
"live_sails_pick_hint": "Tap multiple sails (tap again to deselect), then log.",
|
||||
"live_sails_selected": "Selected: {{sails}}",
|
||||
"live_sails_confirm": "Log entry",
|
||||
"live_sails_confirm_count": "Log entry ({{count}})",
|
||||
"live_sails": "Sails: {{sails}}",
|
||||
"live_fix": "Fix",
|
||||
"live_fix_coords": "Fix {{lat}}, {{lng}}",
|
||||
"live_fix_manual_hint": "GPS unavailable. Enter latitude and longitude manually, or try again with the GPS button.",
|
||||
"live_fix_gps_loading": "Getting GPS position…",
|
||||
"live_fix_invalid": "Please enter valid coordinates (latitude −90…90, longitude −180…180).",
|
||||
"live_fix_lat_placeholder": "Latitude (Lat)",
|
||||
"live_fix_lng_placeholder": "Longitude (Lng)",
|
||||
"live_photo_btn": "Photo (camera)",
|
||||
"live_photo_capture_btn": "Capture",
|
||||
"live_photo_save_btn": "Save",
|
||||
"live_photo_retake_btn": "Retake",
|
||||
"live_photo_capture_failed": "Capture failed. Please try again.",
|
||||
"live_photo_open_camera_btn": "Open camera",
|
||||
"live_photo_native_hint": "Take a photo with your device camera, then save it here.",
|
||||
"live_photo_camera_starting": "Starting camera…",
|
||||
"live_photo_camera_denied": "Camera access denied or unavailable.",
|
||||
"live_photo_camera_unavailable": "Camera is not supported in this browser.",
|
||||
"live_photo_error": "Could not save photo.",
|
||||
"live_photo_entry": "Photo: {{caption}}",
|
||||
"live_photo_entry_plain": "Photo captured",
|
||||
"live_undo_photo_hint": "Photo saved",
|
||||
"live_comment_btn": "Comment",
|
||||
"live_comment_placeholder": "Enter text…",
|
||||
"live_comment_confirm": "Log entry",
|
||||
"live_gps_error": "Could not determine GPS position.",
|
||||
"live_event_generic": "Event",
|
||||
"live_weather_btn": "Weather",
|
||||
"live_weather_owm_btn": "Fetch OpenWeatherMap weather",
|
||||
"live_weather_owm_loading": "Loading weather…",
|
||||
"live_weather_fix_required": "Log a GPS fix first (Fix button) to fetch OpenWeatherMap weather. The position must be at most 6 hours old.",
|
||||
"live_weather_fix_stale": "The last GPS fix is older than 6 hours. Log a new fix before fetching weather.",
|
||||
"live_wind_btn": "Wind",
|
||||
"live_temp_btn": "Temp °C",
|
||||
"live_pressure_btn": "Pressure",
|
||||
"live_precip_btn": "Precipitation",
|
||||
"live_sea_state_btn": "Sea state",
|
||||
"live_course_btn": "Course",
|
||||
"live_fuel_btn": "Fuel",
|
||||
"live_water_btn": "Water",
|
||||
"live_wind_entry": "Wind {{value}}",
|
||||
"live_temp_entry": "Temperature {{temp}} °C",
|
||||
"live_pressure_entry": "Pressure {{value}} hPa",
|
||||
"live_precip_entry": "Precipitation {{value}}",
|
||||
"live_sea_state_entry": "Sea state {{value}}",
|
||||
"live_course_entry": "Course {{course}}",
|
||||
"live_fuel_entry": "Fuel +{{liters}} L",
|
||||
"live_water_entry": "Water +{{liters}} L",
|
||||
"live_auto_position": "Auto position",
|
||||
"live_undo_hint": "Entry saved",
|
||||
"live_undo_btn": "Undo",
|
||||
"live_pressure_placeholder": "e.g. 1013",
|
||||
"live_temp_placeholder": "e.g. 18",
|
||||
"live_precip_placeholder": "e.g. light rain",
|
||||
"live_sea_state_placeholder": "e.g. 3",
|
||||
"live_course_placeholder": "e.g. 245",
|
||||
"live_fuel_placeholder": "Liters refilled",
|
||||
"live_water_placeholder": "Liters refilled",
|
||||
"live_sog_btn": "SOG",
|
||||
"live_stw_btn": "STW",
|
||||
"live_sog_entry": "SOG {{speed}} kn",
|
||||
"live_stw_entry": "STW {{speed}} kn",
|
||||
"live_sog_placeholder": "e.g. 5.2",
|
||||
"live_stw_placeholder": "e.g. 4.8",
|
||||
"live_sog_hint": "Speed over ground (kn) — prefilled from GPS when available.",
|
||||
"delete_entry": "Delete Day",
|
||||
"delete_confirm": "Are you sure you want to permanently delete this travel day?",
|
||||
"carry_over_tanks_title": "Carry over from previous day?",
|
||||
@@ -545,6 +638,8 @@
|
||||
"share_enable": "Enable Public Link",
|
||||
"share_copied": "Link copied!",
|
||||
"share_copy_btn": "Copy Link",
|
||||
"link_qr_hint": "Scan this QR code with your phone",
|
||||
"link_qr_alt": "QR code for the link",
|
||||
"danger_zone_title": "Danger Zone",
|
||||
"danger_zone_desc": "Deleting your account will permanently delete all your passkeys, logbooks, vessel data, crew profiles, travel logs, and E2E keys. This action cannot be undone.",
|
||||
"delete_account_btn": "Permanently Delete Account",
|
||||
@@ -713,7 +808,13 @@
|
||||
"unit_l": "L",
|
||||
"day_label": "Day {{day}}",
|
||||
"account_logbooks": "Logbooks overview",
|
||||
"col_logbook": "Logbook"
|
||||
"col_logbook": "Logbook",
|
||||
"event_series_title": "Event series",
|
||||
"event_series_hint": "Chronological values from the event log.",
|
||||
"event_series_pressure": "Barometric pressure",
|
||||
"event_series_wind": "Wind",
|
||||
"event_series_motor": "Engine",
|
||||
"event_series_empty": "No entries yet."
|
||||
},
|
||||
"tour": {
|
||||
"skip": "Skip tour",
|
||||
|
||||
@@ -68,7 +68,12 @@
|
||||
"enter_pin_placeholder": "Tast inn PIN-koden din...",
|
||||
"decrypt_with_pin": "Dekryptere",
|
||||
"use_recovery_instead": "Bruk gjenopprettingsnøkler i stedet",
|
||||
"error_incorrect_pin": "Feil PIN-kode. Dekryptering mislyktes."
|
||||
"error_incorrect_pin": "Feil PIN-kode. Dekryptering mislyktes.",
|
||||
"error_invalid_host": "Passkeys fungerer ikke via 127.0.0.1. Åpne appen via localhost.",
|
||||
"use_localhost_link": "Bytt til localhost",
|
||||
"error_passkey_cancelled": "Passkey-innlogging ble avbrutt eller utløp. Prøv igjen.",
|
||||
"error_invalid_rp_id": "Passkey-domene stemmer ikke (RP ID). Bruk http://localhost:5173 med RP_ID=localhost i .env for lokal utvikling.",
|
||||
"error_session_incomplete": "Innlogging ufullstendig. Logg inn med passkey igjen."
|
||||
},
|
||||
"pwa": {
|
||||
"title": "Installer app",
|
||||
@@ -197,6 +202,94 @@
|
||||
"saving": "...vil bli reddet...",
|
||||
"saved": "Loggboksiden er vellykket lagret!",
|
||||
"loading": "Tidsskriftet lastes inn...",
|
||||
"view_mode_label": "Visning",
|
||||
"view_list": "Liste",
|
||||
"live_mode": "Live",
|
||||
"live_title": "Live-journal",
|
||||
"live_loading": "Live-journal lastes inn...",
|
||||
"live_retry": "Prøv igjen",
|
||||
"live_load_error": "Live-journal kunne ikke lastes inn.",
|
||||
"live_action_error": "Oppføringen kunne ikke lagres.",
|
||||
"live_open_editor": "Full editor",
|
||||
"live_actions_label": "Hurtighandlinger",
|
||||
"live_stream_label": "Hendelseslogg",
|
||||
"live_stream_title": "Journal",
|
||||
"live_no_events": "Ingen oppføringer ennå — trykk på en handling.",
|
||||
"live_motor_start": "Motor Start",
|
||||
"live_motor_stop": "Motor Stopp",
|
||||
"live_cast_off": "Avreise",
|
||||
"live_moor": "Anløp",
|
||||
"live_sails_btn": "Seil",
|
||||
"live_sails_pick": "Velg seil",
|
||||
"live_sails_pick_hint": "Trykk flere seil (trykk igjen for å fjerne), deretter loggfør.",
|
||||
"live_sails_selected": "Valgt: {{sails}}",
|
||||
"live_sails_confirm": "Loggfør",
|
||||
"live_sails_confirm_count": "Loggfør ({{count}})",
|
||||
"live_sails": "Seil: {{sails}}",
|
||||
"live_fix": "Fix",
|
||||
"live_fix_coords": "Fix {{lat}}, {{lng}}",
|
||||
"live_fix_manual_hint": "GPS ikke tilgjengelig. Skriv inn bredde- og lengdegrad manuelt, eller prøv igjen med GPS-knappen.",
|
||||
"live_fix_gps_loading": "Henter GPS-posisjon…",
|
||||
"live_fix_invalid": "Skriv inn gyldige koordinater (bredde −90…90, lengde −180…180).",
|
||||
"live_fix_lat_placeholder": "Bredde (Lat)",
|
||||
"live_fix_lng_placeholder": "Lengde (Lng)",
|
||||
"live_photo_btn": "Foto (kamera)",
|
||||
"live_photo_capture_btn": "Ta bilde",
|
||||
"live_photo_save_btn": "Lagre",
|
||||
"live_photo_retake_btn": "Ta på nytt",
|
||||
"live_photo_capture_failed": "Opptak mislyktes. Prøv igjen.",
|
||||
"live_photo_open_camera_btn": "Åpne kamera",
|
||||
"live_photo_native_hint": "Ta et bilde med enhetskameraet og lagre det her etterpå.",
|
||||
"live_photo_camera_starting": "Starter kamera…",
|
||||
"live_photo_camera_denied": "Kameratilgang nektet eller utilgjengelig.",
|
||||
"live_photo_camera_unavailable": "Kamera støttes ikke i denne nettleseren.",
|
||||
"live_photo_error": "Kunne ikke lagre foto.",
|
||||
"live_photo_entry": "Foto: {{caption}}",
|
||||
"live_photo_entry_plain": "Foto tatt",
|
||||
"live_undo_photo_hint": "Foto lagret",
|
||||
"live_comment_btn": "Kommentar",
|
||||
"live_comment_placeholder": "Skriv inn tekst…",
|
||||
"live_comment_confirm": "Loggfør",
|
||||
"live_gps_error": "GPS-posisjon kunne ikke bestemmes.",
|
||||
"live_event_generic": "Hendelse",
|
||||
"live_weather_btn": "Vær",
|
||||
"live_weather_owm_btn": "Hent OpenWeatherMap-vær",
|
||||
"live_weather_owm_loading": "Henter vær…",
|
||||
"live_weather_fix_required": "Logg først en GPS-fix (Fix-knapp) for å hente OpenWeatherMap-vær. Posisjonen må være maks 6 timer gammel.",
|
||||
"live_weather_fix_stale": "Siste GPS-fix er eldre enn 6 timer. Logg en ny fix før du henter vær.",
|
||||
"live_wind_btn": "Vind",
|
||||
"live_temp_btn": "T °C",
|
||||
"live_pressure_btn": "Lufttrykk",
|
||||
"live_precip_btn": "Nedbør",
|
||||
"live_sea_state_btn": "Sjøgang",
|
||||
"live_course_btn": "Kurs",
|
||||
"live_fuel_btn": "Diesel",
|
||||
"live_water_btn": "Vann",
|
||||
"live_wind_entry": "Vind {{value}}",
|
||||
"live_temp_entry": "Temperatur {{temp}} °C",
|
||||
"live_pressure_entry": "Lufttrykk {{value}} hPa",
|
||||
"live_precip_entry": "Nedbør {{value}}",
|
||||
"live_sea_state_entry": "Sjøgang {{value}}",
|
||||
"live_course_entry": "Kurs {{course}}",
|
||||
"live_fuel_entry": "Diesel +{{liters}} L",
|
||||
"live_water_entry": "Vann +{{liters}} L",
|
||||
"live_auto_position": "Auto-posisjon",
|
||||
"live_undo_hint": "Oppføring lagret",
|
||||
"live_undo_btn": "Angre",
|
||||
"live_pressure_placeholder": "f.eks. 1013",
|
||||
"live_temp_placeholder": "f.eks. 18",
|
||||
"live_precip_placeholder": "f.eks. lett regn",
|
||||
"live_sea_state_placeholder": "f.eks. 3",
|
||||
"live_course_placeholder": "f.eks. 245",
|
||||
"live_fuel_placeholder": "Påfylte liter",
|
||||
"live_water_placeholder": "Påfylte liter",
|
||||
"live_sog_btn": "SOG",
|
||||
"live_stw_btn": "STW",
|
||||
"live_sog_entry": "SOG {{speed}} kn",
|
||||
"live_stw_entry": "STW {{speed}} kn",
|
||||
"live_sog_placeholder": "f.eks. 5,2",
|
||||
"live_stw_placeholder": "f.eks. 4,8",
|
||||
"live_sog_hint": "Fart over grunn (kn) — GPS-verdi fylles inn hvis tilgjengelig.",
|
||||
"delete_entry": "Slett tagg",
|
||||
"delete_confirm": "Er du sikker på at du vil slette denne reisedagen permanent?",
|
||||
"carry_over_tanks_title": "Overføre data fra dagen før?",
|
||||
@@ -545,6 +638,8 @@
|
||||
"share_enable": "Aktiver offentlig lenke",
|
||||
"share_copied": "Linken er kopiert!",
|
||||
"share_copy_btn": "Kopier lenke",
|
||||
"link_qr_hint": "Skann QR-koden med telefonen",
|
||||
"link_qr_alt": "QR-kode for lenken",
|
||||
"danger_zone_title": "Faresone",
|
||||
"danger_zone_desc": "Hvis du sletter kontoen din, slettes alle dine Passkeys, loggbøker, skipsdata, mannskapsprofiler, reiseoppføringer og E2E-nøkler ugjenkallelig. Denne handlingen kan ikke angres.",
|
||||
"delete_account_btn": "Slett konto ugjenkallelig",
|
||||
@@ -713,7 +808,13 @@
|
||||
"unit_l": "L",
|
||||
"day_label": "Dag {{day}}",
|
||||
"account_logbooks": "Oversikt over loggbøker",
|
||||
"col_logbook": "Loggbok"
|
||||
"col_logbook": "Loggbok",
|
||||
"event_series_title": "Hendelsesforløp",
|
||||
"event_series_hint": "Kronologiske verdier fra hendelsesloggen.",
|
||||
"event_series_pressure": "Lufttrykk",
|
||||
"event_series_wind": "Vind",
|
||||
"event_series_motor": "Motor",
|
||||
"event_series_empty": "Ingen oppføringer ennå."
|
||||
},
|
||||
"tour": {
|
||||
"skip": "Hopp over turen",
|
||||
|
||||
@@ -68,7 +68,12 @@
|
||||
"enter_pin_placeholder": "Ange din PIN-kod...",
|
||||
"decrypt_with_pin": "Dekryptera",
|
||||
"use_recovery_instead": "Använd återställningsnycklar istället",
|
||||
"error_incorrect_pin": "Felaktig PIN-kod. Dekryptering misslyckades."
|
||||
"error_incorrect_pin": "Felaktig PIN-kod. Dekryptering misslyckades.",
|
||||
"error_invalid_host": "Passkeys fungerar inte via 127.0.0.1. Öppna appen via localhost.",
|
||||
"use_localhost_link": "Byt till localhost",
|
||||
"error_passkey_cancelled": "Passkey-inloggning avbröts eller gick ut. Försök igen.",
|
||||
"error_invalid_rp_id": "Passkey-domänen matchar inte (RP ID). Använd http://localhost:5173 med RP_ID=localhost i .env för lokal utveckling.",
|
||||
"error_session_incomplete": "Inloggning ofullständig. Logga in med passkey igen."
|
||||
},
|
||||
"pwa": {
|
||||
"title": "Installera app",
|
||||
@@ -197,6 +202,94 @@
|
||||
"saving": "Kommer att sparas...",
|
||||
"saved": "Loggbokssidan har sparats framgångsrikt!",
|
||||
"loading": "Journalen laddas...",
|
||||
"view_mode_label": "Vy",
|
||||
"view_list": "Lista",
|
||||
"live_mode": "Live",
|
||||
"live_title": "Live-journal",
|
||||
"live_loading": "Live-journal laddas...",
|
||||
"live_retry": "Försök igen",
|
||||
"live_load_error": "Live-journal kunde inte laddas.",
|
||||
"live_action_error": "Posten kunde inte sparas.",
|
||||
"live_open_editor": "Fullständig editor",
|
||||
"live_actions_label": "Snabbåtgärder",
|
||||
"live_stream_label": "Händelselogg",
|
||||
"live_stream_title": "Journal",
|
||||
"live_no_events": "Inga poster ännu — tryck på en åtgärd.",
|
||||
"live_motor_start": "Motor Start",
|
||||
"live_motor_stop": "Motor Stopp",
|
||||
"live_cast_off": "Avgång",
|
||||
"live_moor": "Anlöp",
|
||||
"live_sails_btn": "Segel",
|
||||
"live_sails_pick": "Välj segel",
|
||||
"live_sails_pick_hint": "Tryck på flera segel (tryck igen för att avmarkera), logga sedan.",
|
||||
"live_sails_selected": "Valt: {{sails}}",
|
||||
"live_sails_confirm": "Logga",
|
||||
"live_sails_confirm_count": "Logga ({{count}})",
|
||||
"live_sails": "Segel: {{sails}}",
|
||||
"live_fix": "Fix",
|
||||
"live_fix_coords": "Fix {{lat}}, {{lng}}",
|
||||
"live_fix_manual_hint": "GPS ej tillgänglig. Ange latitud och longitud manuellt, eller försök igen med GPS-knappen.",
|
||||
"live_fix_gps_loading": "Hämtar GPS-position…",
|
||||
"live_fix_invalid": "Ange giltiga koordinater (latitud −90…90, longitud −180…180).",
|
||||
"live_fix_lat_placeholder": "Latitud (Lat)",
|
||||
"live_fix_lng_placeholder": "Longitud (Lng)",
|
||||
"live_photo_btn": "Foto (kamera)",
|
||||
"live_photo_capture_btn": "Ta foto",
|
||||
"live_photo_save_btn": "Spara",
|
||||
"live_photo_retake_btn": "Ta om",
|
||||
"live_photo_capture_failed": "Bildtagning misslyckades. Försök igen.",
|
||||
"live_photo_open_camera_btn": "Öppna kamera",
|
||||
"live_photo_native_hint": "Ta ett foto med enhetens kamera och spara det här efteråt.",
|
||||
"live_photo_camera_starting": "Startar kamera…",
|
||||
"live_photo_camera_denied": "Kameraåtkomst nekad eller ej tillgänglig.",
|
||||
"live_photo_camera_unavailable": "Kameran stöds inte i den här webbläsaren.",
|
||||
"live_photo_error": "Foto kunde inte sparas.",
|
||||
"live_photo_entry": "Foto: {{caption}}",
|
||||
"live_photo_entry_plain": "Foto taget",
|
||||
"live_undo_photo_hint": "Foto sparat",
|
||||
"live_comment_btn": "Kommentar",
|
||||
"live_comment_placeholder": "Ange text…",
|
||||
"live_comment_confirm": "Logga",
|
||||
"live_gps_error": "GPS-position kunde inte bestämmas.",
|
||||
"live_event_generic": "Händelse",
|
||||
"live_weather_btn": "Väder",
|
||||
"live_weather_owm_btn": "Hämta OpenWeatherMap-väder",
|
||||
"live_weather_owm_loading": "Hämtar väder…",
|
||||
"live_weather_fix_required": "Logga först en GPS-fix (Fix-knappen) för att hämta OpenWeatherMap-väder. Positionen får högst vara 6 timmar gammal.",
|
||||
"live_weather_fix_stale": "Senaste GPS-fixen är äldre än 6 timmar. Logga en ny fix innan du hämtar väder.",
|
||||
"live_wind_btn": "Vind",
|
||||
"live_temp_btn": "T °C",
|
||||
"live_pressure_btn": "Lufttryck",
|
||||
"live_precip_btn": "Nederbörd",
|
||||
"live_sea_state_btn": "Sjögang",
|
||||
"live_course_btn": "Kurs",
|
||||
"live_fuel_btn": "Diesel",
|
||||
"live_water_btn": "Vatten",
|
||||
"live_wind_entry": "Vind {{value}}",
|
||||
"live_temp_entry": "Temperatur {{temp}} °C",
|
||||
"live_pressure_entry": "Lufttryck {{value}} hPa",
|
||||
"live_precip_entry": "Nederbörd {{value}}",
|
||||
"live_sea_state_entry": "Sjögang {{value}}",
|
||||
"live_course_entry": "Kurs {{course}}",
|
||||
"live_fuel_entry": "Diesel +{{liters}} L",
|
||||
"live_water_entry": "Vatten +{{liters}} L",
|
||||
"live_auto_position": "Auto-position",
|
||||
"live_undo_hint": "Post sparad",
|
||||
"live_undo_btn": "Ångra",
|
||||
"live_pressure_placeholder": "t.ex. 1013",
|
||||
"live_temp_placeholder": "t.ex. 18",
|
||||
"live_precip_placeholder": "t.ex. lätt regn",
|
||||
"live_sea_state_placeholder": "t.ex. 3",
|
||||
"live_course_placeholder": "t.ex. 245",
|
||||
"live_fuel_placeholder": "Påfyllda liter",
|
||||
"live_water_placeholder": "Påfyllda liter",
|
||||
"live_sog_btn": "SOG",
|
||||
"live_stw_btn": "STW",
|
||||
"live_sog_entry": "SOG {{speed}} kn",
|
||||
"live_stw_entry": "STW {{speed}} kn",
|
||||
"live_sog_placeholder": "t.ex. 5,2",
|
||||
"live_stw_placeholder": "t.ex. 4,8",
|
||||
"live_sog_hint": "Fart över grund (kn) — GPS-värde fylls i om tillgängligt.",
|
||||
"delete_entry": "Ta bort tagg",
|
||||
"delete_confirm": "Är du säker på att du vill radera den här resedagen permanent?",
|
||||
"carry_over_tanks_title": "Överföra data från föregående dag?",
|
||||
@@ -545,6 +638,8 @@
|
||||
"share_enable": "Aktivera offentlig länk",
|
||||
"share_copied": "Länk kopierad!",
|
||||
"share_copy_btn": "Kopiera länk",
|
||||
"link_qr_hint": "Skanna QR-koden med mobilen",
|
||||
"link_qr_alt": "QR-kod för länken",
|
||||
"danger_zone_title": "Farlig zon",
|
||||
"danger_zone_desc": "Om du raderar ditt konto raderas oåterkalleligen alla dina Passkey, loggböcker, fartygsdata, besättningsprofiler, reseanteckningar och E2E-nycklar. Denna åtgärd kan inte ångras.",
|
||||
"delete_account_btn": "Ta bort konto oåterkalleligt",
|
||||
@@ -713,7 +808,13 @@
|
||||
"unit_l": "L",
|
||||
"day_label": "Dag {{day}}__.",
|
||||
"account_logbooks": "Loggböcker i en överblick",
|
||||
"col_logbook": "Loggbok"
|
||||
"col_logbook": "Loggbok",
|
||||
"event_series_title": "Händelseförlopp",
|
||||
"event_series_hint": "Kronologiska värden från händelseloggen.",
|
||||
"event_series_pressure": "Lufttryck",
|
||||
"event_series_wind": "Vind",
|
||||
"event_series_motor": "Motor",
|
||||
"event_series_empty": "Inga poster ännu."
|
||||
},
|
||||
"tour": {
|
||||
"skip": "Hoppa över turen",
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
markReloadAttempt,
|
||||
reconcileVersionOnStartup
|
||||
} from './services/pwaStartup.ts'
|
||||
import { redirectToPasskeyCompatibleHostIfNeeded } from './utils/passkeyHost.ts'
|
||||
|
||||
/** Stale PWA precache on localhost can shadow Vite dev modules. */
|
||||
async function clearDevServiceWorkerCaches(): Promise<void> {
|
||||
@@ -40,6 +41,10 @@ function renderBootstrapError(message: string): void {
|
||||
}
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
if (redirectToPasskeyCompatibleHostIfNeeded()) {
|
||||
return
|
||||
}
|
||||
|
||||
applyAppearanceToDocument()
|
||||
installStaleAssetRecovery()
|
||||
await clearDevServiceWorkerCaches()
|
||||
|
||||
@@ -36,9 +36,17 @@ export const PlausibleEvents = {
|
||||
DEVICE_FORGOTTEN: 'Device Forgotten',
|
||||
RECOVERY_ROTATED: 'Recovery Rotated',
|
||||
LANGUAGE_CHANGED: 'Language Changed',
|
||||
NMEA_IMPORTED: 'NMEA Imported'
|
||||
NMEA_IMPORTED: 'NMEA Imported',
|
||||
NMEA_UPLOADED: 'NMEA Uploaded',
|
||||
LIVE_LOG_OPENED: 'Live Log Opened',
|
||||
LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged',
|
||||
LIVE_LOG_PHOTO_UPLOADED: 'Live Log Photo Uploaded',
|
||||
OWM_WEATHER_FETCHED: 'OWM Weather Fetched'
|
||||
} as const
|
||||
|
||||
/** Where a successful OpenWeatherMap API call originated (no coordinates or place names). */
|
||||
export type OwmAnalyticsSource = 'live_log' | 'entry_editor' | 'entry_editor_gps_lookup'
|
||||
|
||||
export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents]
|
||||
|
||||
export type PlausibleEventProps = Record<string, string | number | boolean>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { clearLogbookKeysCache } from './logbookKeys.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
|
||||
import { db } from './db.js'
|
||||
import { apiFetch, apiJson } from './api.js'
|
||||
import { isWebAuthnUserAbortError } from '../utils/passkeyHost.js'
|
||||
|
||||
const API_BASE = '/api/auth'
|
||||
|
||||
@@ -361,7 +362,11 @@ export async function loginUser(username?: string): Promise<LoginResult> {
|
||||
const prfRequested = !!options.extensions?.prf
|
||||
try {
|
||||
credentialResponse = await startAuthentication({ optionsJSON: options })
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
// User cancelled or timed out — never open a second platform prompt.
|
||||
if (isWebAuthnUserAbortError(err)) {
|
||||
throw err
|
||||
}
|
||||
if (prfRequested) {
|
||||
console.warn('Passkey authentication with PRF extension failed, retrying without PRF:', err)
|
||||
if (options.extensions) {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { getLogbookKey } from './logbookKeys.js'
|
||||
import { decryptJson } from './crypto.js'
|
||||
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
|
||||
import type { LogEventPayload } from '../utils/logEntryPayload.js'
|
||||
import { LIVE_EVENT_CODES } from '../utils/liveEventCodes.js'
|
||||
|
||||
export interface EventSeriesPoint {
|
||||
entryId: string
|
||||
date: string
|
||||
dayOfTravel: string
|
||||
time: string
|
||||
summary: string
|
||||
}
|
||||
|
||||
export interface EventSeriesSummary {
|
||||
pressure: EventSeriesPoint[]
|
||||
wind: EventSeriesPoint[]
|
||||
motor: EventSeriesPoint[]
|
||||
}
|
||||
|
||||
function sortPoints(points: EventSeriesPoint[]): EventSeriesPoint[] {
|
||||
return [...points].sort((a, b) => {
|
||||
const dateCompare = a.date.localeCompare(b.date)
|
||||
if (dateCompare !== 0) return dateCompare
|
||||
return a.time.localeCompare(b.time)
|
||||
})
|
||||
}
|
||||
|
||||
export async function loadLogbookEventSeries(logbookId: string): Promise<EventSeriesSummary> {
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
|
||||
const local = await db.entries.where({ logbookId }).toArray()
|
||||
const decryptedEntries: Array<{
|
||||
entryId: string
|
||||
date: string
|
||||
dayOfTravel: string
|
||||
events: LogEventPayload[]
|
||||
}> = []
|
||||
|
||||
for (const entry of local) {
|
||||
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
|
||||
if (!decrypted) continue
|
||||
decryptedEntries.push({
|
||||
entryId: entry.payloadId,
|
||||
date: String(decrypted.date || ''),
|
||||
dayOfTravel: String(decrypted.dayOfTravel || ''),
|
||||
events: (decrypted.events as LogEventPayload[]) || []
|
||||
})
|
||||
}
|
||||
|
||||
decryptedEntries.sort((a, b) =>
|
||||
compareTravelDaysChronological(
|
||||
{ date: a.date, dayOfTravel: a.dayOfTravel },
|
||||
{ date: b.date, dayOfTravel: b.dayOfTravel }
|
||||
)
|
||||
)
|
||||
|
||||
const pressure: EventSeriesPoint[] = []
|
||||
const wind: EventSeriesPoint[] = []
|
||||
const motor: EventSeriesPoint[] = []
|
||||
|
||||
for (const entry of decryptedEntries) {
|
||||
for (const event of entry.events) {
|
||||
const base = {
|
||||
entryId: entry.entryId,
|
||||
date: entry.date,
|
||||
dayOfTravel: entry.dayOfTravel,
|
||||
time: event.time
|
||||
}
|
||||
|
||||
if (event.windPressure?.trim()) {
|
||||
pressure.push({
|
||||
...base,
|
||||
summary: `${event.windPressure} hPa`
|
||||
})
|
||||
}
|
||||
|
||||
if (event.windDirection?.trim() || event.windStrength?.trim()) {
|
||||
wind.push({
|
||||
...base,
|
||||
summary: [event.windDirection, event.windStrength].filter(Boolean).join(' ')
|
||||
})
|
||||
}
|
||||
|
||||
const code = event.remarks?.trim() ?? ''
|
||||
if (
|
||||
code === LIVE_EVENT_CODES.MOTOR_START ||
|
||||
code === LIVE_EVENT_CODES.MOTOR_STOP
|
||||
) {
|
||||
motor.push({
|
||||
...base,
|
||||
summary: code === LIVE_EVENT_CODES.MOTOR_START ? 'start' : 'stop'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
pressure: sortPoints(pressure),
|
||||
wind: sortPoints(wind),
|
||||
motor: sortPoints(motor)
|
||||
}
|
||||
}
|
||||
@@ -214,6 +214,10 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
|
||||
|
||||
if (response.ok) {
|
||||
const serverLb = await response.json()
|
||||
if (serverLb.id !== localId) {
|
||||
await saveLogbookKey(serverLb.id, logbookKey)
|
||||
await db.logbookKeys.delete(localId)
|
||||
}
|
||||
await db.logbooks.put({
|
||||
id: serverLb.id,
|
||||
encryptedTitle: serverLb.encryptedTitle,
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { getLogbookKey } from './logbookKeys.js'
|
||||
import { encryptJson } from './crypto.js'
|
||||
import { syncLogbook } from './sync.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
|
||||
|
||||
async function getEncryptionKey(logbookId: string): Promise<ArrayBuffer> {
|
||||
const key = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!key) throw new Error('Encryption key not found. Please log in.')
|
||||
return key
|
||||
}
|
||||
|
||||
export async function saveEntryPhoto(options: {
|
||||
logbookId: string
|
||||
entryId: string
|
||||
imageDataUrl: string
|
||||
caption?: string
|
||||
analyticsContext?: string
|
||||
}): Promise<string> {
|
||||
const { logbookId, entryId, imageDataUrl, caption = '', analyticsContext = 'logbook' } = options
|
||||
const masterKey = await getEncryptionKey(logbookId)
|
||||
const photoId = window.crypto.randomUUID()
|
||||
const photoPayload = {
|
||||
image: imageDataUrl,
|
||||
caption: caption.trim()
|
||||
}
|
||||
|
||||
const encrypted = await encryptJson(photoPayload, masterKey)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
await db.photos.put({
|
||||
payloadId: photoId,
|
||||
entryId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
caption: '',
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
await db.syncQueue.put({
|
||||
action: 'create',
|
||||
type: 'photo',
|
||||
payloadId: photoId,
|
||||
logbookId,
|
||||
data: JSON.stringify({
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
entryId
|
||||
}),
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: analyticsContext })
|
||||
if (analyticsContext === 'live_log') {
|
||||
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_PHOTO_UPLOADED)
|
||||
}
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
return photoId
|
||||
}
|
||||
|
||||
export async function deleteEntryPhoto(logbookId: string, photoId: string): Promise<void> {
|
||||
const now = new Date().toISOString()
|
||||
await db.photos.delete(photoId)
|
||||
await db.syncQueue.put({
|
||||
action: 'delete',
|
||||
type: 'photo',
|
||||
payloadId: photoId,
|
||||
logbookId,
|
||||
data: '',
|
||||
updatedAt: now
|
||||
})
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
}
|
||||
|
||||
/** Deletes the newest photo for an entry; returns its id or null. */
|
||||
export async function removeLastPhotoForEntry(
|
||||
logbookId: string,
|
||||
entryId: string
|
||||
): Promise<string | null> {
|
||||
const photos = await db.photos.where({ entryId }).toArray()
|
||||
if (photos.length === 0) return null
|
||||
photos.sort(
|
||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||
)
|
||||
const lastId = photos[0].payloadId
|
||||
await deleteEntryPhoto(logbookId, lastId)
|
||||
return lastId
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,388 @@
|
||||
import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { ensureLogbookKey, getLogbookKey } from './logbookKeys.js'
|
||||
import { decryptJson, encryptJson } from './crypto.js'
|
||||
import { syncLogbook } from './sync.js'
|
||||
import {
|
||||
buildLogEntryPayload,
|
||||
normalizeLogEvent,
|
||||
sortLogEventsByTime,
|
||||
currentLocalTimeHHMM,
|
||||
type LogEventPayload
|
||||
} from '../utils/logEntryPayload.js'
|
||||
import {
|
||||
carryOverFromPreviousDay,
|
||||
compareTravelDaysChronological,
|
||||
getNextTravelDayNumber,
|
||||
type LogEntryTankSource,
|
||||
type TravelDaySortable
|
||||
} from '../utils/logEntryTankLevels.js'
|
||||
|
||||
export interface LoadedEntry {
|
||||
payloadId: string
|
||||
updatedAt: string
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
|
||||
type EncryptedRecord = {
|
||||
encryptedData: string
|
||||
iv: string
|
||||
tag: string
|
||||
}
|
||||
|
||||
async function getMasterKey(logbookId: string): Promise<ArrayBuffer> {
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
return masterKey
|
||||
}
|
||||
|
||||
/** Decrypt one record; skip corrupt or legacy entries instead of aborting the whole scan. */
|
||||
export async function tryDecryptEntryPayload(
|
||||
record: EncryptedRecord,
|
||||
key: ArrayBuffer
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
try {
|
||||
return await decryptJson(record.encryptedData, record.iv, record.tag, key)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function sortEntriesNewestFirst<T extends { updatedAt: string }>(entries: T[]): T[] {
|
||||
return [...entries].sort(
|
||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||
)
|
||||
}
|
||||
|
||||
function tankLevelsFromData(data: Record<string, unknown>) {
|
||||
const fw = (data.freshwater as Record<string, number> | undefined) ?? {
|
||||
morning: 0, refilled: 0, evening: 0, consumption: 0
|
||||
}
|
||||
const fuel = (data.fuel as Record<string, number> | undefined) ?? {
|
||||
morning: 0, refilled: 0, evening: 0, consumption: 0
|
||||
}
|
||||
const gw = data.greywater as { level?: number } | undefined
|
||||
return { fw, fuel, gw }
|
||||
}
|
||||
|
||||
function buildEncryptedPayload(
|
||||
data: Record<string, unknown>,
|
||||
options: {
|
||||
events: LogEventPayload[]
|
||||
departure?: string
|
||||
destination?: string
|
||||
freshwater?: { morning: number; refilled: number; evening: number; consumption: number }
|
||||
fuel?: { morning: number; refilled: number; evening: number; consumption: number }
|
||||
clearSignatures?: boolean
|
||||
}
|
||||
): Record<string, unknown> {
|
||||
const { fw, fuel, gw } = tankLevelsFromData(data)
|
||||
const trackDistance = data.trackDistanceNm
|
||||
const trackSpeedMax = data.trackSpeedMaxKn
|
||||
const trackSpeedAvg = data.trackSpeedAvgKn
|
||||
const motorHoursRaw = data.motorHours
|
||||
|
||||
const freshwater = options.freshwater ?? {
|
||||
morning: fw.morning || 0,
|
||||
refilled: fw.refilled || 0,
|
||||
evening: fw.evening || 0,
|
||||
consumption: fw.consumption ?? 0
|
||||
}
|
||||
const fuelLevels = options.fuel ?? {
|
||||
morning: fuel.morning || 0,
|
||||
refilled: fuel.refilled || 0,
|
||||
evening: fuel.evening || 0,
|
||||
consumption: fuel.consumption ?? 0
|
||||
}
|
||||
|
||||
const payload = buildLogEntryPayload({
|
||||
date: String(data.date || ''),
|
||||
dayOfTravel: String(data.dayOfTravel || ''),
|
||||
departure: options.departure ?? String(data.departure || ''),
|
||||
destination: options.destination ?? String(data.destination || ''),
|
||||
freshwater,
|
||||
fuel: fuelLevels,
|
||||
greywater: gw ? { level: gw.level || 0 } : undefined,
|
||||
trackDistanceNm:
|
||||
trackDistance != null && trackDistance !== ''
|
||||
? parseFloat(String(trackDistance))
|
||||
: undefined,
|
||||
trackSpeedMaxKn:
|
||||
trackSpeedMax != null && trackSpeedMax !== ''
|
||||
? parseFloat(String(trackSpeedMax))
|
||||
: undefined,
|
||||
trackSpeedAvgKn:
|
||||
trackSpeedAvg != null && trackSpeedAvg !== ''
|
||||
? parseFloat(String(trackSpeedAvg))
|
||||
: undefined,
|
||||
motorHours:
|
||||
motorHoursRaw != null && motorHoursRaw !== ''
|
||||
? parseFloat(String(motorHoursRaw))
|
||||
: undefined,
|
||||
events: options.events
|
||||
})
|
||||
|
||||
const clear = options.clearSignatures
|
||||
return {
|
||||
...payload,
|
||||
signSkipper: clear ? '' : (data.signSkipper ?? ''),
|
||||
signCrew: clear ? '' : (data.signCrew ?? '')
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadEntry(logbookId: string, entryId: string): Promise<LoadedEntry | null> {
|
||||
const masterKey = await getMasterKey(logbookId)
|
||||
const record = await db.entries.get(entryId)
|
||||
if (!record) return null
|
||||
const data = await tryDecryptEntryPayload(record, masterKey)
|
||||
if (!data) return null
|
||||
return { payloadId: record.payloadId, updatedAt: record.updatedAt, data }
|
||||
}
|
||||
|
||||
export async function findTodayEntryId(logbookId: string): Promise<string | null> {
|
||||
const todayStr = new Date().toISOString().substring(0, 10)
|
||||
const masterKey = await getMasterKey(logbookId)
|
||||
const local = sortEntriesNewestFirst(await db.entries.where({ logbookId }).toArray())
|
||||
|
||||
for (const entry of local) {
|
||||
const decrypted = await tryDecryptEntryPayload(entry, masterKey)
|
||||
if (decrypted && String(decrypted.date) === todayStr) {
|
||||
return entry.payloadId
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function createTodayEntry(logbookId: string): Promise<string> {
|
||||
const masterKey = await getMasterKey(logbookId)
|
||||
const localEntries = await db.entries.where({ logbookId }).toArray()
|
||||
const decryptedEntries: Array<LogEntryTankSource & TravelDaySortable> = []
|
||||
|
||||
if (localEntries.length > 0) {
|
||||
for (const entry of localEntries) {
|
||||
const decrypted = await tryDecryptEntryPayload(entry, masterKey)
|
||||
if (decrypted) {
|
||||
decryptedEntries.push(decrypted as LogEntryTankSource & TravelDaySortable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
decryptedEntries.sort(compareTravelDaysChronological)
|
||||
const previousEntry = decryptedEntries.at(-1) ?? null
|
||||
const { freshwater, fuel, greywaterLevel, departure } = carryOverFromPreviousDay(previousEntry)
|
||||
|
||||
const localId = window.crypto.randomUUID()
|
||||
const nowStr = new Date().toISOString()
|
||||
const todayStr = nowStr.substring(0, 10)
|
||||
|
||||
const initialPayload = {
|
||||
date: todayStr,
|
||||
dayOfTravel: getNextTravelDayNumber(decryptedEntries),
|
||||
departure,
|
||||
destination: '',
|
||||
freshwater,
|
||||
fuel,
|
||||
...(greywaterLevel > 0 ? { greywater: { level: greywaterLevel } } : {}),
|
||||
signSkipper: '',
|
||||
signCrew: '',
|
||||
events: []
|
||||
}
|
||||
|
||||
const encrypted = await encryptJson(initialPayload, masterKey)
|
||||
|
||||
await db.entries.put({
|
||||
payloadId: localId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: nowStr
|
||||
})
|
||||
|
||||
await db.syncQueue.put({
|
||||
action: 'create',
|
||||
type: 'entry',
|
||||
payloadId: localId,
|
||||
logbookId,
|
||||
data: JSON.stringify(encrypted),
|
||||
updatedAt: nowStr
|
||||
})
|
||||
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
return localId
|
||||
}
|
||||
|
||||
export async function findOrCreateTodayEntry(logbookId: string): Promise<string> {
|
||||
const id = logbookId.trim()
|
||||
if (!id) throw new Error('Logbook id required')
|
||||
|
||||
await ensureLogbookKey(id)
|
||||
|
||||
const entryCount = await db.entries.where({ logbookId: id }).count()
|
||||
if (entryCount === 0) {
|
||||
return createTodayEntry(id)
|
||||
}
|
||||
|
||||
const existing = await findTodayEntryId(id)
|
||||
if (existing) return existing
|
||||
return createTodayEntry(id)
|
||||
}
|
||||
|
||||
export interface AppendQuickEventResult {
|
||||
events: LogEventPayload[]
|
||||
hadSignature: boolean
|
||||
}
|
||||
|
||||
export async function appendQuickEvent(
|
||||
logbookId: string,
|
||||
entryId: string,
|
||||
partialEvent: Partial<LogEventPayload>,
|
||||
headerPatch?: { departure?: string; destination?: string }
|
||||
): 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[]) || []
|
||||
const newEvent = normalizeLogEvent({
|
||||
time: currentLocalTimeHHMM(),
|
||||
...partialEvent
|
||||
})
|
||||
const nextEvents = sortLogEventsByTime([...currentEvents, newEvent])
|
||||
|
||||
await persistEntry(logbookId, entryId, loaded.data, {
|
||||
events: nextEvents,
|
||||
departure: headerPatch?.departure,
|
||||
destination: headerPatch?.destination,
|
||||
clearSignatures: 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(
|
||||
logbookId: string,
|
||||
entryId: string,
|
||||
data: Record<string, unknown>,
|
||||
options: Parameters<typeof buildEncryptedPayload>[1]
|
||||
): Promise<void> {
|
||||
const hadSignature = !!(data.signSkipper || data.signCrew)
|
||||
const entryData = buildEncryptedPayload(data, {
|
||||
...options,
|
||||
clearSignatures: options.clearSignatures ?? hadSignature
|
||||
})
|
||||
|
||||
const masterKey = await getMasterKey(logbookId)
|
||||
const encrypted = await encryptJson(entryData, masterKey)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
await db.entries.put({
|
||||
payloadId: entryId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
await db.syncQueue.put({
|
||||
action: 'update',
|
||||
type: 'entry',
|
||||
payloadId: entryId,
|
||||
logbookId,
|
||||
data: JSON.stringify(encrypted),
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
}
|
||||
|
||||
export async function removeLastEvent(
|
||||
logbookId: string,
|
||||
entryId: string
|
||||
): Promise<LogEventPayload[]> {
|
||||
const loaded = await loadEntry(logbookId, entryId)
|
||||
if (!loaded) throw new Error('Entry not found')
|
||||
|
||||
const currentEvents = (loaded.data.events as LogEventPayload[]) || []
|
||||
if (currentEvents.length === 0) return []
|
||||
|
||||
const nextEvents = sortLogEventsByTime(currentEvents.slice(0, -1))
|
||||
await persistEntry(logbookId, entryId, loaded.data, { events: nextEvents })
|
||||
return nextEvents
|
||||
}
|
||||
|
||||
export async function appendTankRefill(
|
||||
logbookId: string,
|
||||
entryId: string,
|
||||
tank: 'fuel' | 'freshwater',
|
||||
addLiters: number,
|
||||
event: Partial<LogEventPayload>
|
||||
): Promise<AppendQuickEventResult> {
|
||||
const loaded = await loadEntry(logbookId, entryId)
|
||||
if (!loaded) throw new Error('Entry not found')
|
||||
|
||||
const { fw, fuel } = tankLevelsFromData(loaded.data)
|
||||
const currentEvents = (loaded.data.events as LogEventPayload[]) || []
|
||||
const newEvent = normalizeLogEvent({
|
||||
time: currentLocalTimeHHMM(),
|
||||
...event
|
||||
})
|
||||
const nextEvents = sortLogEventsByTime([...currentEvents, newEvent])
|
||||
|
||||
const tankPatch = tank === 'fuel'
|
||||
? {
|
||||
fuel: {
|
||||
morning: fuel.morning || 0,
|
||||
refilled: (fuel.refilled || 0) + addLiters,
|
||||
evening: fuel.evening || 0,
|
||||
consumption: fuel.consumption ?? 0
|
||||
}
|
||||
}
|
||||
: {
|
||||
freshwater: {
|
||||
morning: fw.morning || 0,
|
||||
refilled: (fw.refilled || 0) + addLiters,
|
||||
evening: fw.evening || 0,
|
||||
consumption: fw.consumption ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
const hadSignature = !!(loaded.data.signSkipper || loaded.data.signCrew)
|
||||
await persistEntry(logbookId, entryId, loaded.data, {
|
||||
events: nextEvents,
|
||||
...tankPatch,
|
||||
clearSignatures: hadSignature
|
||||
})
|
||||
|
||||
return { events: nextEvents, hadSignature }
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PlausibleEvents } from './analytics.js'
|
||||
|
||||
const apiFetch = vi.fn()
|
||||
const trackPlausibleEvent = vi.fn()
|
||||
|
||||
vi.mock('./api.js', () => ({ apiFetch }))
|
||||
vi.mock('./analytics.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./analytics.js')>()
|
||||
return {
|
||||
...actual,
|
||||
trackPlausibleEvent: (...args: unknown[]) => trackPlausibleEvent(...args)
|
||||
}
|
||||
})
|
||||
vi.mock('./userPreferences.js', () => ({
|
||||
getOwmApiKeyForActiveUser: () => ''
|
||||
}))
|
||||
|
||||
describe('fetchOpenWeatherCurrent', () => {
|
||||
beforeEach(() => {
|
||||
apiFetch.mockReset()
|
||||
trackPlausibleEvent.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('tracks OWM Weather Fetched on success when analyticsSource is set', async () => {
|
||||
apiFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ coord: { lat: 54, lon: 10 }, main: { temp: 20 } })
|
||||
})
|
||||
|
||||
const { fetchOpenWeatherCurrent } = await import('./weather.js')
|
||||
await fetchOpenWeatherCurrent(
|
||||
{ lat: '54.0', lon: '10.0' },
|
||||
{ analyticsSource: 'live_log' }
|
||||
)
|
||||
|
||||
expect(trackPlausibleEvent).toHaveBeenCalledWith(PlausibleEvents.OWM_WEATHER_FETCHED, {
|
||||
source: 'live_log'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not track when the API request fails', async () => {
|
||||
apiFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({ error: 'fail' })
|
||||
})
|
||||
|
||||
const { fetchOpenWeatherCurrent, WeatherApiError } = await import('./weather.js')
|
||||
await expect(
|
||||
fetchOpenWeatherCurrent({ lat: '54', lon: '10' }, { analyticsSource: 'entry_editor' })
|
||||
).rejects.toBeInstanceOf(WeatherApiError)
|
||||
|
||||
expect(trackPlausibleEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,10 @@
|
||||
import { apiFetch } from './api.js'
|
||||
import { getOwmApiKeyForActiveUser } from './userPreferences.js'
|
||||
import {
|
||||
type OwmAnalyticsSource,
|
||||
PlausibleEvents,
|
||||
trackPlausibleEvent
|
||||
} from './analytics.js'
|
||||
|
||||
export class WeatherApiError extends Error {
|
||||
code: 'NO_KEY' | 'REQUEST_FAILED'
|
||||
@@ -11,11 +16,16 @@ export class WeatherApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchOpenWeatherCurrent(params: {
|
||||
lat?: string
|
||||
lon?: string
|
||||
q?: string
|
||||
}): Promise<Record<string, unknown>> {
|
||||
const OWM_FETCH_TIMEOUT_MS = 20_000
|
||||
|
||||
export async function fetchOpenWeatherCurrent(
|
||||
params: {
|
||||
lat?: string
|
||||
lon?: string
|
||||
q?: string
|
||||
},
|
||||
options?: { analyticsSource: OwmAnalyticsSource }
|
||||
): Promise<Record<string, unknown>> {
|
||||
const searchParams = new URLSearchParams()
|
||||
|
||||
if (params.lat && params.lon) {
|
||||
@@ -31,7 +41,22 @@ export async function fetchOpenWeatherCurrent(params: {
|
||||
const headers: Record<string, string> = {}
|
||||
if (userKey) headers['X-OWM-Api-Key'] = userKey
|
||||
|
||||
const res = await apiFetch(`/api/weather/current?${searchParams.toString()}`, { headers })
|
||||
const controller = new AbortController()
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), OWM_FETCH_TIMEOUT_MS)
|
||||
let res: Response
|
||||
try {
|
||||
res = await apiFetch(`/api/weather/current?${searchParams.toString()}`, {
|
||||
headers,
|
||||
signal: controller.signal
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
throw new WeatherApiError('Weather request timed out')
|
||||
}
|
||||
throw err
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId)
|
||||
}
|
||||
|
||||
if (res.status === 503) {
|
||||
throw new WeatherApiError('No OpenWeatherMap API key configured', 'NO_KEY')
|
||||
@@ -42,5 +67,11 @@ export async function fetchOpenWeatherCurrent(params: {
|
||||
throw new WeatherApiError('Weather API rejected the request')
|
||||
}
|
||||
|
||||
if (options?.analyticsSource) {
|
||||
trackPlausibleEvent(PlausibleEvents.OWM_WEATHER_FETCHED, {
|
||||
source: options.analyticsSource
|
||||
})
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { captureVideoFrame, preferNativeCameraPicker } from './captureVideoFrame.js'
|
||||
|
||||
describe('preferNativeCameraPicker', () => {
|
||||
it('returns true on Android user agents', () => {
|
||||
vi.stubGlobal('navigator', { ...navigator, userAgent: 'Mozilla/5.0 (Linux; Android 14)' })
|
||||
expect(preferNativeCameraPicker()).toBe(true)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('returns false on desktop without touch', () => {
|
||||
vi.stubGlobal('navigator', {
|
||||
...navigator,
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0)',
|
||||
maxTouchPoints: 0
|
||||
})
|
||||
vi.stubGlobal('matchMedia', () => ({
|
||||
matches: false,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {}
|
||||
}))
|
||||
Object.defineProperty(window, 'ontouchstart', { value: undefined, configurable: true })
|
||||
expect(preferNativeCameraPicker()).toBe(false)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
})
|
||||
|
||||
describe('captureVideoFrame', () => {
|
||||
it('throws when video dimensions are zero', async () => {
|
||||
const video = { videoWidth: 0, videoHeight: 0 } as HTMLVideoElement
|
||||
await expect(captureVideoFrame(video)).rejects.toThrow('video_frame_not_ready')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
/** Capture current video frame as JPEG blob (with Android-safe fallbacks). */
|
||||
export async function captureVideoFrame(video: HTMLVideoElement, quality = 0.92): Promise<Blob> {
|
||||
const width = video.videoWidth
|
||||
const height = video.videoHeight
|
||||
if (!width || !height) {
|
||||
throw new Error('video_frame_not_ready')
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) {
|
||||
throw new Error('canvas_context_unavailable')
|
||||
}
|
||||
ctx.drawImage(video, 0, 0, width, height)
|
||||
|
||||
const blob = await canvasToJpegBlob(canvas, quality)
|
||||
if (blob) return blob
|
||||
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', quality)
|
||||
const response = await fetch(dataUrl)
|
||||
const fallback = await response.blob()
|
||||
if (!fallback.size) {
|
||||
throw new Error('capture_encode_failed')
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
function canvasToJpegBlob(canvas: HTMLCanvasElement, quality: number): Promise<Blob | null> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false
|
||||
const finish = (blob: Blob | null) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
window.clearTimeout(timer)
|
||||
resolve(blob)
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => finish(null), 3000)
|
||||
|
||||
try {
|
||||
canvas.toBlob((blob) => finish(blob), 'image/jpeg', quality)
|
||||
} catch {
|
||||
finish(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Mobile: native camera via file input is more reliable than getUserMedia + canvas. */
|
||||
export function preferNativeCameraPicker(): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
const ua = navigator.userAgent
|
||||
if (/Android|iPhone|iPad|iPod/i.test(ua)) return true
|
||||
const touch = 'ontouchstart' in window || navigator.maxTouchPoints > 0
|
||||
const coarse = window.matchMedia('(pointer: coarse)').matches
|
||||
const narrow = window.matchMedia('(max-width: 768px)').matches
|
||||
return touch && (coarse || narrow)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isMotorRunningFromEvents,
|
||||
LIVE_EVENT_CODES,
|
||||
liveCommentRemark,
|
||||
liveSailsRemark,
|
||||
liveSogRemark,
|
||||
parseLiveCommentRemark,
|
||||
livePhotoRemark,
|
||||
parseLiveSailsRemark
|
||||
} from './liveEventCodes.js'
|
||||
import { formatEventSummary } from './formatEventSummary.js'
|
||||
import { normalizeLogEvent } from './logEntryPayload.js'
|
||||
|
||||
const t = (key: string, opts?: Record<string, unknown>) => {
|
||||
const map: Record<string, string> = {
|
||||
'logs.live_motor_start': 'Motor Start',
|
||||
'logs.live_motor_stop': 'Motor Stop',
|
||||
'logs.live_cast_off': 'Cast off',
|
||||
'logs.live_moor': 'Moor',
|
||||
'logs.live_sails': `Sails: ${opts?.sails ?? ''}`,
|
||||
'logs.live_fix': 'Fix',
|
||||
'logs.live_fix_coords': `Fix ${opts?.lat}, ${opts?.lng}`,
|
||||
'logs.live_event_generic': 'Event',
|
||||
'logs.live_temp_entry': `Temperature ${opts?.temp} °C`,
|
||||
'logs.live_pressure_entry': `Pressure ${opts?.value} hPa`,
|
||||
'logs.live_wind_entry': `Wind ${opts?.value}`,
|
||||
'logs.live_photo_entry': `Photo: ${opts?.caption}`,
|
||||
'logs.live_photo_entry_plain': 'Photo captured',
|
||||
'logs.live_course_entry': `Course ${opts?.course}`,
|
||||
'logs.live_sog_entry': `SOG ${opts?.speed} kn`,
|
||||
'logs.live_stw_entry': `STW ${opts?.speed} kn`,
|
||||
'logs.event_mgk': 'Course',
|
||||
'logs.event_wind_pressure': 'Pressure'
|
||||
}
|
||||
return map[key] ?? key
|
||||
}
|
||||
|
||||
describe('liveEventCodes', () => {
|
||||
it('derives motor running from last motor event', () => {
|
||||
const events = [
|
||||
{ remarks: LIVE_EVENT_CODES.MOTOR_START },
|
||||
{ remarks: LIVE_EVENT_CODES.MOTOR_STOP },
|
||||
{ remarks: LIVE_EVENT_CODES.MOTOR_START }
|
||||
]
|
||||
expect(isMotorRunningFromEvents(events)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when last motor event is stop', () => {
|
||||
const events = [
|
||||
{ remarks: LIVE_EVENT_CODES.MOTOR_START },
|
||||
{ remarks: LIVE_EVENT_CODES.MOTOR_STOP }
|
||||
]
|
||||
expect(isMotorRunningFromEvents(events)).toBe(false)
|
||||
})
|
||||
|
||||
it('parses sail and comment remarks', () => {
|
||||
expect(parseLiveSailsRemark(liveSailsRemark('Main + Genoa'))).toBe('Main + Genoa')
|
||||
expect(parseLiveCommentRemark(liveCommentRemark('Wind dreht'))).toBe('Wind dreht')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatEventSummary', () => {
|
||||
it('formats live motor start', () => {
|
||||
const event = normalizeLogEvent({ time: '08:10', remarks: LIVE_EVENT_CODES.MOTOR_START })
|
||||
expect(formatEventSummary(event, t)).toBe('Motor Start')
|
||||
})
|
||||
|
||||
it('formats sails remark', () => {
|
||||
const event = normalizeLogEvent({
|
||||
time: '08:20',
|
||||
remarks: liveSailsRemark('Main + Genoa'),
|
||||
sailsOrMotor: 'Main + Genoa'
|
||||
})
|
||||
expect(formatEventSummary(event, t)).toBe('Sails: Main + Genoa')
|
||||
})
|
||||
|
||||
it('formats fix with coordinates', () => {
|
||||
const event = normalizeLogEvent({
|
||||
time: '09:00',
|
||||
remarks: LIVE_EVENT_CODES.FIX,
|
||||
gpsLat: '54.323000',
|
||||
gpsLng: '10.145000'
|
||||
})
|
||||
expect(formatEventSummary(event, t)).toBe('Fix 54.323000, 10.145000')
|
||||
})
|
||||
|
||||
it('formats pressure entry', () => {
|
||||
const event = normalizeLogEvent({
|
||||
time: '09:00',
|
||||
remarks: LIVE_EVENT_CODES.PRESSURE,
|
||||
windPressure: '1013'
|
||||
})
|
||||
expect(formatEventSummary(event, t)).toBe('Pressure 1013 hPa')
|
||||
})
|
||||
|
||||
it('formats SOG entry', () => {
|
||||
const event = normalizeLogEvent({
|
||||
time: '10:15',
|
||||
remarks: liveSogRemark('5.2')
|
||||
})
|
||||
expect(formatEventSummary(event, t)).toBe('SOG 5.2 kn')
|
||||
})
|
||||
|
||||
it('formats STW entry', () => {
|
||||
const event = normalizeLogEvent({
|
||||
time: '10:20',
|
||||
remarks: '__live:stw:4.8'
|
||||
})
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { TFunction } from 'i18next'
|
||||
import type { LogEventPayload } from './logEntryPayload.js'
|
||||
import {
|
||||
LIVE_EVENT_CODES,
|
||||
parseLiveCommentRemark,
|
||||
parseLiveFuelRemark,
|
||||
parseLivePhotoRemark,
|
||||
parseLivePrecipRemark,
|
||||
parseLiveSailsRemark,
|
||||
parseLiveSogRemark,
|
||||
parseLiveStwRemark,
|
||||
parseLiveTempRemark,
|
||||
parseLiveWaterRemark
|
||||
} from './liveEventCodes.js'
|
||||
|
||||
export function formatEventSummary(event: LogEventPayload, t: TFunction): string {
|
||||
const code = event.remarks.trim()
|
||||
|
||||
if (code === LIVE_EVENT_CODES.MOTOR_START) return t('logs.live_motor_start')
|
||||
if (code === LIVE_EVENT_CODES.MOTOR_STOP) return t('logs.live_motor_stop')
|
||||
if (code === LIVE_EVENT_CODES.CAST_OFF) return t('logs.live_cast_off')
|
||||
if (code === LIVE_EVENT_CODES.MOOR) return t('logs.live_moor')
|
||||
|
||||
const sails = parseLiveSailsRemark(code)
|
||||
if (sails) return t('logs.live_sails', { sails })
|
||||
|
||||
const comment = parseLiveCommentRemark(code)
|
||||
if (comment) return comment
|
||||
|
||||
const photo = parseLivePhotoRemark(code)
|
||||
if (photo !== null) {
|
||||
return photo
|
||||
? t('logs.live_photo_entry', { caption: photo })
|
||||
: t('logs.live_photo_entry_plain')
|
||||
}
|
||||
|
||||
const temp = parseLiveTempRemark(code)
|
||||
if (temp) return t('logs.live_temp_entry', { temp })
|
||||
|
||||
const precip = parseLivePrecipRemark(code)
|
||||
if (precip) return t('logs.live_precip_entry', { value: precip })
|
||||
|
||||
const fuel = parseLiveFuelRemark(code)
|
||||
if (fuel) return t('logs.live_fuel_entry', { liters: fuel })
|
||||
|
||||
const water = parseLiveWaterRemark(code)
|
||||
if (water) return t('logs.live_water_entry', { liters: water })
|
||||
|
||||
const sog = parseLiveSogRemark(code)
|
||||
if (sog) return t('logs.live_sog_entry', { speed: sog })
|
||||
|
||||
const stw = parseLiveStwRemark(code)
|
||||
if (stw) return t('logs.live_stw_entry', { speed: stw })
|
||||
|
||||
if (code === LIVE_EVENT_CODES.FIX || code === LIVE_EVENT_CODES.AUTO_POSITION) {
|
||||
if (event.gpsLat && event.gpsLng) {
|
||||
const label = code === LIVE_EVENT_CODES.AUTO_POSITION
|
||||
? t('logs.live_auto_position')
|
||||
: t('logs.live_fix')
|
||||
return `${label} ${event.gpsLat}, ${event.gpsLng}`
|
||||
}
|
||||
return code === LIVE_EVENT_CODES.AUTO_POSITION
|
||||
? t('logs.live_auto_position')
|
||||
: t('logs.live_fix')
|
||||
}
|
||||
|
||||
if (code === LIVE_EVENT_CODES.COURSE && event.mgk) {
|
||||
return t('logs.live_course_entry', { course: event.mgk })
|
||||
}
|
||||
|
||||
if (code === LIVE_EVENT_CODES.WIND) {
|
||||
const wind = [event.windDirection, event.windStrength].filter(Boolean).join(' ')
|
||||
return wind ? t('logs.live_wind_entry', { value: wind }) : t('logs.live_wind_btn')
|
||||
}
|
||||
|
||||
if (code === LIVE_EVENT_CODES.PRESSURE && event.windPressure) {
|
||||
return t('logs.live_pressure_entry', { value: event.windPressure })
|
||||
}
|
||||
|
||||
if (code === LIVE_EVENT_CODES.SEA_STATE && event.seaState) {
|
||||
return t('logs.live_sea_state_entry', { value: event.seaState })
|
||||
}
|
||||
|
||||
if (code && !code.startsWith('__live:')) {
|
||||
return code
|
||||
}
|
||||
|
||||
const parts: string[] = []
|
||||
if (event.sailsOrMotor) parts.push(event.sailsOrMotor)
|
||||
if (event.mgk) parts.push(`${t('logs.event_mgk')} ${event.mgk}`)
|
||||
if (event.windDirection || event.windStrength) {
|
||||
parts.push([event.windDirection, event.windStrength].filter(Boolean).join(' '))
|
||||
}
|
||||
if (event.windPressure) parts.push(`${t('logs.event_wind_pressure')}: ${event.windPressure}`)
|
||||
if (event.gpsLat && event.gpsLng) {
|
||||
parts.push(`${event.gpsLat}, ${event.gpsLng}`)
|
||||
}
|
||||
|
||||
return parts.join(' · ') || t('logs.live_event_generic')
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
const MPS_TO_KNOTS = 1.9438444924406
|
||||
|
||||
export interface GeoCoordinates {
|
||||
lat: string
|
||||
lng: string
|
||||
/** SOG from GPS when available (kn), otherwise 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> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!navigator.geolocation) {
|
||||
reject(new Error('geolocation_unavailable'))
|
||||
return
|
||||
}
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
const speedKn = pos.coords.speed != null && Number.isFinite(pos.coords.speed)
|
||||
? Number((pos.coords.speed * MPS_TO_KNOTS).toFixed(1))
|
||||
: null
|
||||
resolve({
|
||||
lat: pos.coords.latitude.toFixed(6),
|
||||
lng: pos.coords.longitude.toFixed(6),
|
||||
speedKn
|
||||
})
|
||||
},
|
||||
(err) => reject(err),
|
||||
{ enableHighAccuracy: true, timeout: timeoutMs, maximumAge: 0 }
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/** Machine-readable live-log markers stored in event.remarks (locale-independent). */
|
||||
export const LIVE_EVENT_CODES = {
|
||||
MOTOR_START: '__live:motor_start',
|
||||
MOTOR_STOP: '__live:motor_stop',
|
||||
CAST_OFF: '__live:cast_off',
|
||||
MOOR: '__live:moor',
|
||||
FIX: '__live:fix',
|
||||
AUTO_POSITION: '__live:auto_position',
|
||||
COURSE: '__live:course',
|
||||
WIND: '__live:wind',
|
||||
PRESSURE: '__live:pressure',
|
||||
SEA_STATE: '__live:sea_state'
|
||||
} as const
|
||||
|
||||
export type LiveEventCode = (typeof LIVE_EVENT_CODES)[keyof typeof LIVE_EVENT_CODES]
|
||||
|
||||
export function liveSailsRemark(sails: string): string {
|
||||
return `__live:sails:${sails}`
|
||||
}
|
||||
|
||||
export function liveCommentRemark(text: string): string {
|
||||
return `__live:comment:${text}`
|
||||
}
|
||||
|
||||
export function liveTempRemark(tempC: string): string {
|
||||
return `__live:temp:${tempC}`
|
||||
}
|
||||
|
||||
export function livePrecipRemark(text: string): string {
|
||||
return `__live:precip:${text}`
|
||||
}
|
||||
|
||||
export function liveFuelRemark(liters: string): string {
|
||||
return `__live:fuel:${liters}`
|
||||
}
|
||||
|
||||
export function liveWaterRemark(liters: string): string {
|
||||
return `__live:water:${liters}`
|
||||
}
|
||||
|
||||
export function livePhotoRemark(caption?: string): string {
|
||||
const text = caption?.trim()
|
||||
return text ? `__live:photo:${text}` : '__live:photo'
|
||||
}
|
||||
|
||||
export function parseLivePhotoRemark(remarks: string): string | null {
|
||||
if (remarks === '__live:photo') return ''
|
||||
const prefix = '__live:photo:'
|
||||
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
|
||||
}
|
||||
|
||||
export function liveSogRemark(speedKn: string): string {
|
||||
return `__live:sog:${speedKn}`
|
||||
}
|
||||
|
||||
export function liveStwRemark(speedKn: string): string {
|
||||
return `__live:stw:${speedKn}`
|
||||
}
|
||||
|
||||
export function parseLiveSailsRemark(remarks: string): string | null {
|
||||
const prefix = '__live:sails:'
|
||||
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
|
||||
}
|
||||
|
||||
export function parseLiveCommentRemark(remarks: string): string | null {
|
||||
const prefix = '__live:comment:'
|
||||
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
|
||||
}
|
||||
|
||||
export function parseLiveTempRemark(remarks: string): string | null {
|
||||
const prefix = '__live:temp:'
|
||||
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
|
||||
}
|
||||
|
||||
export function parseLivePrecipRemark(remarks: string): string | null {
|
||||
const prefix = '__live:precip:'
|
||||
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
|
||||
}
|
||||
|
||||
export function parseLiveFuelRemark(remarks: string): string | null {
|
||||
const prefix = '__live:fuel:'
|
||||
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
|
||||
}
|
||||
|
||||
export function parseLiveWaterRemark(remarks: string): string | null {
|
||||
const prefix = '__live:water:'
|
||||
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
|
||||
}
|
||||
|
||||
export function parseLiveSogRemark(remarks: string): string | null {
|
||||
const prefix = '__live:sog:'
|
||||
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
|
||||
}
|
||||
|
||||
export function parseLiveStwRemark(remarks: string): string | null {
|
||||
const prefix = '__live:stw:'
|
||||
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
|
||||
}
|
||||
|
||||
/** Derive motor running state from event history (survives reload). */
|
||||
export function isMotorRunningFromEvents(
|
||||
events: Array<{ remarks: string }>,
|
||||
motorStartCode: string = LIVE_EVENT_CODES.MOTOR_START,
|
||||
motorStopCode: string = LIVE_EVENT_CODES.MOTOR_STOP
|
||||
): boolean {
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const code = events[i].remarks.trim()
|
||||
if (code === motorStartCode) return true
|
||||
if (code === motorStopCode) return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function eventTimestampMs(date: string, time: string): number | null {
|
||||
const normalized = time.trim().match(/^(\d{1,2}):(\d{2})/)
|
||||
if (!normalized || !date) return null
|
||||
const hours = parseInt(normalized[1], 10)
|
||||
const minutes = parseInt(normalized[2], 10)
|
||||
if (hours > 23 || minutes > 59) return null
|
||||
const parsed = new Date(`${date}T${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:00`)
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed.getTime()
|
||||
}
|
||||
|
||||
export function getLastAutoPositionMs(
|
||||
events: Array<{ remarks: string; time: string }>,
|
||||
entryDate: string
|
||||
): number | null {
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
if (events[i].remarks.trim() !== LIVE_EVENT_CODES.AUTO_POSITION) continue
|
||||
return eventTimestampMs(entryDate, events[i].time)
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -46,6 +46,8 @@ export default defineConfig({
|
||||
include: ['leaflet']
|
||||
},
|
||||
server: {
|
||||
// Passkeys require localhost or a real domain — not 127.0.0.1
|
||||
host: 'localhost',
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# Code-Statistik — Kapteins Daagbok
|
||||
|
||||
Erstellt am **31. Mai 2026** mit [cloc](https://github.com/AlDanial/cloc) v1.98.
|
||||
|
||||
## Methode
|
||||
|
||||
```bash
|
||||
cloc . \
|
||||
--exclude-dir=node_modules,dist,.git,userfeedback,.cursor,.planning \
|
||||
--md
|
||||
```
|
||||
|
||||
Ausgeschlossen: Build-Artefakte (`dist/`), Abhängigkeiten (`node_modules/`), lokales Feedback, Cursor-/Planungs-Artefakte.
|
||||
|
||||
## Gesamtübersicht
|
||||
|
||||
| Language | files | blank | comment | code |
|
||||
| :--- | ---: | ---: | ---: | ---: |
|
||||
| TypeScript | 145 | 3012 | 540 | 23599 |
|
||||
| JSON | 14 | 4 | 0 | 15005 |
|
||||
| CSS | 3 | 743 | 45 | 4837 |
|
||||
| XML | 3 | 0 | 0 | 4302 |
|
||||
| HTML | 5 | 160 | 0 | 1411 |
|
||||
| Markdown | 8 | 390 | 12 | 1077 |
|
||||
| JavaScript | 8 | 117 | 43 | 709 |
|
||||
| Bourne Shell | 3 | 81 | 21 | 412 |
|
||||
| YAML | 1 | 3 | 0 | 55 |
|
||||
| Dockerfile | 2 | 20 | 21 | 39 |
|
||||
| SVG | 4 | 0 | 0 | 27 |
|
||||
| **SUM** | **196** | **4530** | **682** | **51473** |
|
||||
|
||||
### Anwendungscode (TypeScript, JavaScript, CSS)
|
||||
|
||||
Ohne JSON, GPX/XML, HTML, Docs und Assets — näher an der eigentlichen Implementierung:
|
||||
|
||||
| Language | files | blank | comment | code |
|
||||
| :--- | ---: | ---: | ---: | ---: |
|
||||
| TypeScript | 145 | 3012 | 540 | 23599 |
|
||||
| CSS | 3 | 743 | 45 | 4837 |
|
||||
| JavaScript | 8 | 117 | 43 | 709 |
|
||||
| **SUM** | **156** | **3872** | **628** | **29145** |
|
||||
|
||||
> **Hinweis:** Der hohe JSON-Anteil (~15k Zeilen) stammt überwiegend aus i18n-Locale-Dateien (`client/src/i18n/locales/*.json`). XML (~4,3k Zeilen) sind Demo-GPX-Tracks unter `client/src/assets/demo/`.
|
||||
|
||||
## Aufteilung nach Bereich
|
||||
|
||||
| Bereich | Dateien | Leer | Kommentar | Code |
|
||||
| :--- | ---: | ---: | ---: | ---: |
|
||||
| `client/` | 154 | 3398 | 557 | 43534 |
|
||||
| `server/` | 20 | 399 | 54 | 4426 |
|
||||
| `scripts/` | 9 | 193 | 59 | 1065 |
|
||||
| `docs/` | 8 | 418 | 0 | 2079 |
|
||||
|
||||
### `client/`
|
||||
|
||||
| Language | files | blank | comment | code |
|
||||
| :--- | ---: | ---: | ---: | ---: |
|
||||
| TypeScript | 129 | 2625 | 499 | 21291 |
|
||||
| JSON | 10 | 4 | 0 | 12898 |
|
||||
| CSS | 3 | 743 | 45 | 4837 |
|
||||
| XML | 3 | 0 | 0 | 4302 |
|
||||
| Markdown | 1 | 13 | 0 | 60 |
|
||||
| JavaScript | 2 | 5 | 5 | 56 |
|
||||
| HTML | 1 | 0 | 0 | 47 |
|
||||
| SVG | 4 | 0 | 0 | 27 |
|
||||
| Dockerfile | 1 | 8 | 8 | 16 |
|
||||
| **SUM** | **154** | **3398** | **557** | **43534** |
|
||||
|
||||
### `server/`
|
||||
|
||||
| Language | files | blank | comment | code |
|
||||
| :--- | ---: | ---: | ---: | ---: |
|
||||
| TypeScript | 16 | 387 | 41 | 2308 |
|
||||
| JSON | 3 | 0 | 0 | 2095 |
|
||||
| Dockerfile | 1 | 12 | 13 | 23 |
|
||||
| **SUM** | **20** | **399** | **54** | **4426** |
|
||||
|
||||
### `scripts/`
|
||||
|
||||
| Language | files | blank | comment | code |
|
||||
| :--- | ---: | ---: | ---: | ---: |
|
||||
| JavaScript | 6 | 112 | 38 | 653 |
|
||||
| Bourne Shell | 3 | 81 | 21 | 412 |
|
||||
| **SUM** | **9** | **193** | **59** | **1065** |
|
||||
|
||||
## Größte Quelldateien (TypeScript & CSS)
|
||||
|
||||
| Datei | blank | comment | code |
|
||||
| :--- | ---: | ---: | ---: |
|
||||
| `client/src/App.css` | 730 | 31 | 4430 |
|
||||
| `client/src/components/LogEntryEditor.tsx` | 176 | 17 | 1929 |
|
||||
| `client/src/components/UserProfilePage.tsx` | 52 | 0 | 746 |
|
||||
| `client/src/components/LiveLogView.tsx` | 50 | 2 | 711 |
|
||||
| `client/src/App.tsx` | 85 | 21 | 656 |
|
||||
| `client/src/components/CrewForm.tsx` | 82 | 117 | 644 |
|
||||
| `client/src/components/VesselForm.tsx` | 55 | 8 | 558 |
|
||||
| `client/src/services/auth.ts` | 80 | 66 | 556 |
|
||||
| `client/src/services/logbookBackup.ts` | 56 | 0 | 545 |
|
||||
| `client/src/components/AuthOnboarding.tsx` | 49 | 25 | 542 |
|
||||
| `client/src/components/StatsDashboard.tsx` | 43 | 0 | 521 |
|
||||
| `client/src/components/LogbookDashboard.tsx` | 46 | 2 | 508 |
|
||||
| `client/src/components/InvitationAcceptance.tsx` | 59 | 0 | 461 |
|
||||
| `client/src/components/LogEntriesList.tsx` | 50 | 4 | 447 |
|
||||
| `client/src/services/sync.ts` | 70 | 29 | 428 |
|
||||
|
||||
## Kurzfassung
|
||||
|
||||
- **~51k** physische Codezeilen gesamt (inkl. Locales, Demo-GPX, Docs).
|
||||
- **~29k** Zeilen reiner Anwendungscode (TS/JS/CSS).
|
||||
- **~21k** TypeScript im Client, **~2,3k** im Server.
|
||||
- Größte Einzeldatei: `App.css` (~4,4k Zeilen), größte Komponente: `LogEntryEditor.tsx` (~1,9k Zeilen).
|
||||
|
||||
## Report aktualisieren
|
||||
|
||||
```bash
|
||||
cloc . \
|
||||
--exclude-dir=node_modules,dist,.git,userfeedback,.cursor,.planning \
|
||||
--md > docs/cloc-report-raw.md
|
||||
```
|
||||
|
||||
Für eine reine Markdown-Tabelle reicht `--md`; dieser Report fasst mehrere cloc-Läufe manuell zusammen.
|
||||
@@ -157,7 +157,7 @@
|
||||
display: flex;
|
||||
gap: 2.5mm;
|
||||
align-items: flex-start;
|
||||
font-size: 10.5pt;
|
||||
font-size: 8.5pt;
|
||||
line-height: 1.28;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
@@ -320,6 +320,8 @@
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Simpelt login uden adgangskode Passkey.</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Ende-til-ende-kryptering</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Upload af GPS-spor (GPX/KML) med kortvisning</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Automatisk loggenerering fra NMEA-data</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Live-log (klik-til-log)</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Rute-statistik</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Vedhæftede billeder pr. rejsedag</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Fotoavatarbilleder til skipper og besætning</span></div>
|
||||
|
||||
@@ -157,7 +157,7 @@
|
||||
display: flex;
|
||||
gap: 2.5mm;
|
||||
align-items: flex-start;
|
||||
font-size: 10.5pt;
|
||||
font-size: 8.5pt;
|
||||
line-height: 1.28;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
@@ -320,6 +320,8 @@
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Einfache passwortlose Passkey-Anmeldung</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Ende-zu-Ende Verschlüsselung</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>GPS-Track Upload (GPX/KML) mit Kartendarstellung</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Automatische Log-Erstellung aus NMEA-Daten</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Live-Log (Click-to-Log)</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Streckenstatistik</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Foto-Anhänge pro Reisetag</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Foto-Avatarbilder für Skipper und Crew</span></div>
|
||||
|
||||
@@ -157,7 +157,7 @@
|
||||
display: flex;
|
||||
gap: 2.5mm;
|
||||
align-items: flex-start;
|
||||
font-size: 10.5pt;
|
||||
font-size: 8.5pt;
|
||||
line-height: 1.28;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
@@ -309,7 +309,7 @@
|
||||
</header>
|
||||
|
||||
<p class="intro">
|
||||
Oppbevar loggboken om bord digitalt: reisedager, GPS-spor, mannskaps- og skipsdata
|
||||
Før loggboken om bord digitalt: reisedager, GPS-spor, mannskaps- og skipsdata
|
||||
<strong>Ende-til-ende-kryptert</strong>kan installeres som en app og
|
||||
<strong>også offline</strong> kan brukes til sjøs.
|
||||
</p>
|
||||
@@ -320,6 +320,8 @@
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Enkel passordfri Passkey-pålogging</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Ende-til-ende-kryptering</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Opplasting av GPS-spor (GPX/KML) med kartvisning</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Automatisk logggenerering fra NMEA-data</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Live-logg (klikk-til-logg)</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Rutestatistikk</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Fotobilag per reisedag</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Avatarbilder for skipper og mannskap</span></div>
|
||||
@@ -349,7 +351,7 @@
|
||||
</section>
|
||||
|
||||
<section class="beta-box">
|
||||
<h2>Betafasen - dine tilbakemeldinger teller</h2>
|
||||
<h2>Betafase - dine tilbakemeldinger teller</h2>
|
||||
<p>
|
||||
Kapteins Daagbok er en<strong>Privat hobbyprosjekt uten profitthensikt</strong>.
|
||||
Som betatester bidrar du til å forbedre appen for skippere og mannskap i hverdagen - tilbakemeldinger er hjertelig velkomne.
|
||||
@@ -366,7 +368,7 @@
|
||||
<p>Åpne i nettleseren eller legg til som en app på startskjermen. Registrer deg med Passkey - ingen appbutikk er nødvendig.</p>
|
||||
<div class="tags">
|
||||
<span class="tag">Kostnadsfritt</span>
|
||||
<span class="tag">Gratis annonsering</span>
|
||||
<span class="tag">Reklame gratis</span>
|
||||
<span class="tag">E2E-kryptert</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -157,7 +157,7 @@
|
||||
display: flex;
|
||||
gap: 2.5mm;
|
||||
align-items: flex-start;
|
||||
font-size: 10.5pt;
|
||||
font-size: 8.5pt;
|
||||
line-height: 1.28;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
@@ -320,6 +320,8 @@
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Enkel lösenordsfri Passkey-inloggning</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>End-to-end-kryptering</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Uppladdning av GPS-spår (GPX/KML) med kartvisning</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Automatisk logggenerering från NMEA-data</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Live-logg (klicka för att logga)</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Statistik över rutter</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Fotobilagor per resdag</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Fotoavatarbilder för skeppare och besättning</span></div>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.3 MiB |
@@ -21,7 +21,8 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
|
||||
| Travel Day Saved | Reisetag gespeichert (`LogEntryEditor.tsx`) | — |
|
||||
| Entry Signed | Passkey-Signatur Skipper oder Crew (`LogEntryEditor.tsx`) | `role`: `skipper` \| `crew` |
|
||||
| GPS Track Uploaded | GPX/KML/GeoJSON hochgeladen (`LogEntryEditor.tsx`) | — |
|
||||
| NMEA Imported | NMEA-Protokoll in Journal übernommen (`NmeaImportWizard.tsx`) | `mode`: `interval` \| `change` \| `both`, `events`, `track` (Anzahlen/Flags, keine Koordinaten) |
|
||||
| NMEA Uploaded | NMEA-Datei erfolgreich gelesen und geparst (`NmeaImportWizard.tsx`) | `lines` (Anzahl Sätze), `candidates` (Vorschläge für Reisetag), `duplicate` (Datei schon importiert), `has_position` |
|
||||
| NMEA Imported | NMEA-Vorschläge in Journal übernommen (`NmeaImportWizard.tsx`) | `mode`: `interval` \| `change` \| `both`, `events` (übernommene Einträge), `track` (GPS-Track mit importiert) |
|
||||
| Vessel Saved | Schiffsdaten gespeichert (`VesselForm.tsx`) | — |
|
||||
| Crew Saved | Skipper- oder Crew-Profil gespeichert (`CrewForm.tsx`) | `role`: `skipper` \| `crew`, `action`: `create` \| `update` |
|
||||
| Account Deleted | Konto erfolgreich gelöscht (`auth.ts`) | — |
|
||||
@@ -35,7 +36,9 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
|
||||
| PDF Exported | PDF-Export eines Reisetags (`LogEntryEditor.tsx`, `LogEntriesList.tsx`) | `scope`: `entry` |
|
||||
| CSV Exported | CSV-Download aus der Eintragsliste (`LogEntriesList.tsx`) | — |
|
||||
| CSV Shared | CSV über Web Share API geteilt (`LogEntriesList.tsx`) | — |
|
||||
| Photo Uploaded | Foto hochgeladen (`PhotoCapture.tsx`, `CrewForm.tsx`) | `context`: `logbook` \| `crew`, bei Crew zusätzlich `role`: `skipper` \| `crew` |
|
||||
| Photo Uploaded | Foto hochgeladen (`photoAttachments.ts`, `PhotoCapture.tsx`, `CrewForm.tsx`) | `context`: `logbook` \| `live_log` \| `crew`, bei Crew zusätzlich `role`: `skipper` \| `crew` |
|
||||
| Live Log Photo Uploaded | Foto im Live-Journal per Kamera gespeichert (`photoAttachments.ts`, `analyticsContext`: `live_log`) | — |
|
||||
| OWM Weather Fetched | Erfolgreicher OpenWeatherMap-API-Abruf (`weather.ts`, zentral nach HTTP 200) | `source`: siehe [OWM-Quellen](#owm-quellen) |
|
||||
| Backup Exported | Backup-Datei heruntergeladen (`LogbookBackupPanel.tsx`) | `entries`, `photos` (Anzahlen, keine Inhalte) |
|
||||
| Backup Restored | Backup wiederhergestellt (`LogbookBackupPanel.tsx`) | `entries`, `photos`, `mode`: `same_id` \| `overwrite` \| `new_id` |
|
||||
| Push Enabled | Crew-Änderungs-Push aktiviert (`PushNotificationSettings.tsx`) | — |
|
||||
@@ -51,6 +54,45 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
|
||||
| Device Forgotten | Account aus Schnell-Login-Liste dieses Geräts entfernt (`UserProfilePage.tsx`) | — |
|
||||
| Recovery Rotated | Neuer 12-Wörter-Wiederherstellungsschlüssel erstellt (`UserProfilePage.tsx`) | — |
|
||||
| Language Changed | Sprache über UI-Wechsler gewählt (`i18nLanguages.ts` via Sprach-Button in App, Dashboard, Auth, Demo, Einladung, Share-Viewer) | `from`, `to`: ISO 639-1 (`de`, `en`, `da`, `sv`, `nb`) |
|
||||
| Live Log Opened | Live-Journal-Ansicht geladen (`LiveLogView.tsx`, einmal pro Mount nach erfolgreichem Init) | — |
|
||||
| Live Log Event Logged | Quick-Action erfolgreich ins heutige Journal geschrieben (`LiveLogView.tsx`) | `action`: siehe [Live-Log-Aktionen](#live-log-aktionen) |
|
||||
|
||||
### Live-Log-Aktionen
|
||||
|
||||
Property `action` bei **Live Log Event Logged** — stabile englische Schlüssel, keine Inhalte (kein Kurs, kein Kommentartext, keine Koordinaten):
|
||||
|
||||
| `action` | Button / Auslöser |
|
||||
|----------|-------------------|
|
||||
| `motor_start` | Motor Start |
|
||||
| `motor_stop` | Motor Stop |
|
||||
| `cast_off` | Ablegen |
|
||||
| `moor` | Anlegen |
|
||||
| `sails` | Segel (Modal bestätigt) |
|
||||
| `course` | Kurs (Dial/Modal bestätigt) |
|
||||
| `sog` | SOG |
|
||||
| `stw` | STW |
|
||||
| `fuel` | Diesel-Tank |
|
||||
| `water` | Wasser-Tank |
|
||||
| `wind` | Wind (Richtung/Stärke) |
|
||||
| `pressure` | Luftdruck |
|
||||
| `temp` | Temperatur |
|
||||
| `precip` | Niederschlag |
|
||||
| `sea_state` | Seegang |
|
||||
| `fix` | GPS-Fix (manuell) |
|
||||
| `comment` | Kommentar |
|
||||
| `undo` | Letztes Ereignis rückgängig |
|
||||
|
||||
### OWM-Quellen
|
||||
|
||||
Property `source` bei **OWM Weather Fetched** — ein Event pro erfolgreichem API-Call (keine Koordinaten, kein Ortsname):
|
||||
|
||||
| `source` | Auslöser |
|
||||
|----------|----------|
|
||||
| `live_log` | OpenWeatherMap-Wetter im Live-Journal (`LiveLogView.tsx`) |
|
||||
| `entry_editor` | Wetter-Button im Reisetag-Editor (`LogEntryEditor.tsx`, `handleFetchWeather`) |
|
||||
| `entry_editor_gps_lookup` | GPS-Fallback per Ortsname im Reisetag-Editor (`LogEntryEditor.tsx`, `handleGetGps`) |
|
||||
|
||||
Fehlgeschlagene Abrufe (kein API-Key, Timeout, leere Antwort) lösen **kein** Event aus.
|
||||
|
||||
## Bewusst nicht getrackt
|
||||
|
||||
@@ -59,6 +101,10 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
|
||||
- **PII:** Keine Inhalte aus verschlüsselten Logbüchern in Properties.
|
||||
- **Profil-KPIs:** Statistik-Karten und User-ID-Kopieren werden nicht getrackt (reine Anzeige bzw. zu granular).
|
||||
- **Sprache bei Erstbesuch:** Automatische Browser-/URL-Erkennung (`i18next-browser-languagedetector`, `?lng=`) löst kein `Language Changed` aus — nur explizite Klicks auf den Sprach-Button.
|
||||
- **Live-Log Auto-Position:** Hintergrund-GPS alle 3 h (`LIVE_EVENT_CODES.AUTO_POSITION`) — automatisch, best-effort, kein Nutzer-Tap.
|
||||
- **Live-Log Modals:** Öffnen/Abbrechen von Dialogen ohne Speichern; Wechsel Liste ↔ Live (nur `Live Log Opened` beim erneuten Mount).
|
||||
- **Live-Log Editor-Link:** Öffnen des vollständigen Editors aus der Live-Ansicht.
|
||||
- **NMEA-Import:** Abbrechen, Vorschau ohne Übernahme, Archiv-Entscheid (Archivieren/Verwerfen); fehlgeschlagene Datei-Lesevorgänge.
|
||||
- **Kontolöschung:** `Account Deleted` bleibt in `auth.ts` — unabhängig davon, ob die Gefahrenzone auf der Profilseite oder früher in den Einstellungen genutzt wurde.
|
||||
|
||||
## Typische Funnels (Plausible Goals)
|
||||
@@ -73,7 +119,9 @@ Empfohlene Goal-Ketten für Auswertung (nur Business!):
|
||||
6. **Datensicherung:** Backup Exported → Backup Restored
|
||||
7. **Kontosicherheit:** Profile Opened → Passkey Added / Local PIN Set / Recovery Rotated; Last Passkey Remove Hinted → Account Deleted (selten, aber aussagekräftig)
|
||||
8. **Internationalisierung:** Language Changed (Verteilung `to`, Pfade mit Übersetzungs-Feedback)
|
||||
9. **NMEA-Import:** NMEA Imported (Modus, Anzahl übernommener Ereignisse, optional Track)
|
||||
9. **NMEA-Import:** NMEA Uploaded → NMEA Imported (Modus, `events`, optional Track; Upload-Funnel vs. Abbruch)
|
||||
10. **Live-Journal:** Live Log Opened → Live Log Event Logged (Verteilung `action`; z. B. `fix`, `course`, `motor_start`) → Live Log Photo Uploaded
|
||||
11. **OpenWeatherMap:** OWM Weather Fetched (Verteilung `source`; Live-Journal vs. Reisetag-Editor)
|
||||
|
||||
## Entwicklung
|
||||
|
||||
@@ -83,6 +131,11 @@ import { PlausibleEvents, trackPlausibleEvent } from './services/analytics.js'
|
||||
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
|
||||
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'skipper' })
|
||||
trackPlausibleEvent(PlausibleEvents.LANGUAGE_CHANGED, { from: 'de', to: 'da' })
|
||||
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'course' })
|
||||
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_PHOTO_UPLOADED)
|
||||
trackPlausibleEvent(PlausibleEvents.OWM_WEATHER_FETCHED, { source: 'live_log' })
|
||||
trackPlausibleEvent(PlausibleEvents.NMEA_UPLOADED, { lines: 1200, candidates: 8, duplicate: false, has_position: true })
|
||||
trackPlausibleEvent(PlausibleEvents.NMEA_IMPORTED, { mode: 'both', events: 6, track: true })
|
||||
```
|
||||
|
||||
Lokal ohne Plausible-Script ist `trackPlausibleEvent` ein No-Op. In Production im Browser-Netzwerk-Tab auf Requests an die Plausible-Instanz prüfen.
|
||||
|
||||
@@ -49,6 +49,21 @@ function parseColorSchemePreference(value: unknown): string | null {
|
||||
return typeof value === 'string' && VALID_COLOR_SCHEMES.has(value) ? value : null
|
||||
}
|
||||
|
||||
function isMissingAppearancePrefsTable(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
(error as { code: string }).code === 'P2021'
|
||||
)
|
||||
}
|
||||
|
||||
const DEFAULT_APPEARANCE_PREFS = {
|
||||
theme: 'auto',
|
||||
colorScheme: 'auto',
|
||||
persisted: false
|
||||
} as const
|
||||
|
||||
router.post('/register-options', async (req, res) => {
|
||||
try {
|
||||
const { username } = req.body
|
||||
@@ -448,9 +463,14 @@ router.get('/appearance-prefs', requireUser, async (req: any, res) => {
|
||||
colorScheme: prefs?.colorScheme ?? 'auto',
|
||||
persisted: prefs != null
|
||||
})
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
if (isMissingAppearancePrefsTable(error)) {
|
||||
console.warn('UserAppearancePrefs table missing — run: npx prisma db push (in server/)')
|
||||
return res.json({ ...DEFAULT_APPEARANCE_PREFS })
|
||||
}
|
||||
console.error('Error reading appearance prefs:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
const message = error instanceof Error ? error.message : 'Internal server error'
|
||||
return res.status(500).json({ error: message })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -482,9 +502,16 @@ router.put('/appearance-prefs', requireUser, async (req: any, res) => {
|
||||
colorScheme: prefs.colorScheme,
|
||||
persisted: true
|
||||
})
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
if (isMissingAppearancePrefsTable(error)) {
|
||||
console.warn('UserAppearancePrefs table missing — run: npx prisma db push (in server/)')
|
||||
return res.status(503).json({
|
||||
error: 'Appearance preferences storage is not migrated. Run prisma db push on the server.'
|
||||
})
|
||||
}
|
||||
console.error('Error updating appearance prefs:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
const message = error instanceof Error ? error.message : 'Internal server error'
|
||||
return res.status(500).json({ error: message })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||