Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c6c2779f2 | |||
| b6c4e9e7d9 | |||
| 04c6be2b5b | |||
| 9089d017b6 | |||
| f8dc6ace3c | |||
| 18f14d7e0b | |||
| 0edf4a789c | |||
| 4ef56aeb8f | |||
| 3263fbcec3 | |||
| b9ce853059 | |||
| 3d8a505bd9 | |||
| e138752dd3 | |||
| b9c908169b | |||
| e6bde5c525 | |||
| eab7b86c0b | |||
| b86789ae4c | |||
| 2a8ec2fccf | |||
| 60a8533a44 | |||
| c86ac4273c | |||
| 73467f2263 | |||
| e068f083c1 | |||
| f083294db5 | |||
| 8fc15081e2 | |||
| efa0fcf934 | |||
| c1ecdcad9c | |||
| d6c7952af8 | |||
| 3d02f841a0 | |||
| 0caaf681d8 | |||
| 43dc994c4f | |||
| d94502097e | |||
| a36ca2facb | |||
| b7a1085d52 | |||
| 3925c6f822 | |||
| 0b2c1c22c6 | |||
| aa03573e1f | |||
| a0b8664e23 | |||
| 74282f50d0 | |||
| 5b47415d55 | |||
| 039e4e2736 | |||
| 35bfbc1043 | |||
| 6c866dbad5 | |||
| bb667afec8 | |||
| beee33f842 |
@@ -5,13 +5,26 @@ OpenWeatherMapAPIKey=<owm_api_key>
|
|||||||
DeepLAPIKey=
|
DeepLAPIKey=
|
||||||
|
|
||||||
# Passkey configuration (WebAuthn Relying Party ID and Origin)
|
# Passkey configuration (WebAuthn Relying Party ID and Origin)
|
||||||
# For local dev: localhost and http://localhost
|
# For local dev: use localhost (NOT 127.0.0.1 — browsers reject IP addresses for Passkeys)
|
||||||
# For production: e.g. kapteins-daagbok.eu and https://kapteins-daagbok.eu
|
# Production (kapteins-daagbok.eu):
|
||||||
|
# RP_ID=kapteins-daagbok.eu
|
||||||
|
# ORIGIN=https://kapteins-daagbok.eu
|
||||||
RP_ID=localhost
|
RP_ID=localhost
|
||||||
# Must match the frontend URL (Vite dev: http://localhost:5173; Docker: http://localhost)
|
# Must match the frontend URL exactly (Vite dev: http://localhost:5173; Docker: http://localhost)
|
||||||
ORIGIN=http://localhost:5173
|
ORIGIN=http://localhost:5173
|
||||||
# Optional: comma-separated CORS origins (defaults to ORIGIN; dev also allows 127.0.0.1:5173)
|
|
||||||
# CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
# Behind Nginx Proxy Manager — see docs/deployment/npm-security.md
|
||||||
|
# TRUST_PROXY=172.16.10.10
|
||||||
|
# TRUST_PROXY=1
|
||||||
|
|
||||||
|
# Docker Compose database (required for production deploy)
|
||||||
|
# Generate: openssl rand -hex 24
|
||||||
|
# Rotate on running server: ./scripts/rotate-postgres-password.sh (see docs/deployment/postgres-password.md)
|
||||||
|
# POSTGRES_USER=postgres
|
||||||
|
# POSTGRES_PASSWORD=
|
||||||
|
# POSTGRES_DB=daagbox
|
||||||
|
# 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)
|
# API session signing (min. 32 chars; required in production)
|
||||||
# Generate: openssl rand -base64 48
|
# Generate: openssl rand -base64 48
|
||||||
|
|||||||
@@ -11,3 +11,5 @@ server/dist/
|
|||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
userfeedback/
|
||||||
|
|||||||
@@ -219,13 +219,19 @@ cd server && npx prisma db push && cd ..
|
|||||||
| Health Check | http://localhost:5000/api/health |
|
| Health Check | http://localhost:5000/api/health |
|
||||||
| Public Demo | http://localhost:5173/demo |
|
| Public Demo | http://localhost:5173/demo |
|
||||||
|
|
||||||
### 5. Tests (Frontend)
|
### 5. Qualität & Tests
|
||||||
|
|
||||||
|
Vor jedem Deploy auf [kapteins-daagbok.eu](https://kapteins-daagbok.eu/) (kein externes CI):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd client && npm test
|
npm run check
|
||||||
|
# oder: ./scripts/predeploy-check.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Vitest-Unit-Tests für Utils, i18n und Services (z. B. Kurswinkel, Benutzereinstellungen).
|
Einzeln: `npm test` (Client + Server) · `npm run build` · optional `npm run lint` (Client, noch nicht in `check`)
|
||||||
|
|
||||||
|
- **Client:** Vitest für Utils, i18n, Services
|
||||||
|
- **Server:** Smoke-Tests (`/api/health`, Auth-Guards) mit Supertest — siehe `server/src/api.smoke.test.ts`
|
||||||
|
|
||||||
## Docker (produktionsnah)
|
## Docker (produktionsnah)
|
||||||
|
|
||||||
@@ -237,11 +243,12 @@ Gesamten Stack lokal bauen und starten:
|
|||||||
|
|
||||||
Frontend: http://localhost · API: http://localhost/api/health · Demo: http://localhost/demo
|
Frontend: http://localhost · API: http://localhost/api/health · Demo: http://localhost/demo
|
||||||
|
|
||||||
Umgebungsvariablen in `.env` setzen — mindestens `RP_ID`, `ORIGIN` (z. B. `http://localhost`) und `SESSION_SECRET`. Für Push die VAPID-Variablen an den Backend-Container durchreichen (`docker-compose.yml` → `backend.environment`). Für Feedback `NTFY_*` setzen.
|
Umgebungsvariablen in `.env` setzen — mindestens `RP_ID`, `ORIGIN` (z. B. `http://localhost`), `SESSION_SECRET` und für Docker Compose `POSTGRES_PASSWORD`. Für Push die VAPID-Variablen an den Backend-Container durchreichen (`docker-compose.yml` → `backend.environment`). Für Feedback `NTFY_*` setzen.
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen):
|
Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen). Führt vor dem SSH-Deploy automatisch [`predeploy-check.sh`](scripts/predeploy-check.sh) aus (`npm run check`):
|
||||||
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/update-prod.sh
|
./scripts/update-prod.sh
|
||||||
@@ -249,12 +256,17 @@ Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen):
|
|||||||
|
|
||||||
Standard-Ziel: `root@10.0.0.25:/opt/kapteins-daagbok` — per `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_DIR` überschreibbar.
|
Standard-Ziel: `root@10.0.0.25:/opt/kapteins-daagbok` — per `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_DIR` überschreibbar.
|
||||||
|
|
||||||
Auf dem Server müssen `server/.env` (oder gleichwertige Umgebung) u. a. `DATABASE_URL`, `RP_ID`, `ORIGIN`, `SESSION_SECRET` (≥ 32 Zeichen) und bei Push `VAPID_*` enthalten. Optional `NTFY_*` für Feedback. Nach Schema-Änderungen: `npx prisma db push` im Backend-Container.
|
Auf dem Server müssen `.env` u. a. `POSTGRES_PASSWORD`, `RP_ID`, `ORIGIN` (`https://kapteins-daagbok.eu`), `SESSION_SECRET` (≥ 32 Zeichen), `TRUST_PROXY` (NPM, z. B. `172.16.10.10` oder `1`) und bei Push `VAPID_*` enthalten. Optional `NTFY_*` für Feedback. Nach Schema-Änderungen: `npx prisma db push` im Backend-Container.
|
||||||
|
|
||||||
|
Hinter **Nginx Proxy Manager**: [docs/deployment/npm-security.md](docs/deployment/npm-security.md).
|
||||||
|
|
||||||
## Dokumentation
|
## Dokumentation
|
||||||
|
|
||||||
| Dokument | Inhalt |
|
| Dokument | Inhalt |
|
||||||
|----------|--------|
|
|----------|--------|
|
||||||
|
| [docs/deployment/npm-security.md](docs/deployment/npm-security.md) | NPM, TLS, `trust proxy`, Security-Header |
|
||||||
|
| [docs/deployment/predeploy.md](docs/deployment/predeploy.md) | Pre-Deploy-Checks ohne CI |
|
||||||
|
| [docs/deployment/postgres-password.md](docs/deployment/postgres-password.md) | PostgreSQL-Passwort rotieren / App-Rolle |
|
||||||
| [docs/plausible-events.md](docs/plausible-events.md) | Custom Events für Plausible Analytics |
|
| [docs/plausible-events.md](docs/plausible-events.md) | Custom Events für Plausible Analytics |
|
||||||
| [docs/push-notifications-plan.md](docs/push-notifications-plan.md) | Web Push: Architektur, API, Testplan |
|
| [docs/push-notifications-plan.md](docs/push-notifications-plan.md) | Web Push: Architektur, API, Testplan |
|
||||||
| [docs/plan-compass-course-dial.md](docs/plan-compass-course-dial.md) | Kompass-Dial: UX- und Implementierungsplan |
|
| [docs/plan-compass-course-dial.md](docs/plan-compass-course-dial.md) | Kompass-Dial: UX- und Implementierungsplan |
|
||||||
|
|||||||
@@ -3,15 +3,32 @@ server {
|
|||||||
server_name localhost;
|
server_name localhost;
|
||||||
client_max_body_size 50M;
|
client_max_body_size 50M;
|
||||||
|
|
||||||
|
# Security headers (TLS/HSTS at NPM — see docs/deployment/npm-security.md)
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=()" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
|
||||||
|
|
||||||
# Service worker and app shell must revalidate so PWA updates are detected
|
# Service worker and app shell must revalidate so PWA updates are detected
|
||||||
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ {
|
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=()" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
|
||||||
}
|
}
|
||||||
|
|
||||||
location = /index.html {
|
location = /index.html {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
add_header Cache-Control "no-cache, must-revalidate";
|
add_header Cache-Control "no-cache, must-revalidate" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=()" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
|
||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^1.16.0",
|
"lucide-react": "^1.16.0",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-i18next": "^17.0.8"
|
"react-i18next": "^17.0.8"
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/node": "^24.12.3",
|
"@types/node": "^24.12.3",
|
||||||
|
"@types/qrcode": "^1.5.5",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^4.7.0",
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
@@ -34,7 +36,6 @@
|
|||||||
"globals": "^17.6.0",
|
"globals": "^17.6.0",
|
||||||
"happy-dom": "^20.9.0",
|
"happy-dom": "^20.9.0",
|
||||||
"playwright": "^1.51.0",
|
"playwright": "^1.51.0",
|
||||||
"qrcode": "^1.5.4",
|
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"typescript-eslint": "^8.59.2",
|
"typescript-eslint": "^8.59.2",
|
||||||
"vite": "^6.3.5",
|
"vite": "^6.3.5",
|
||||||
@@ -2970,6 +2971,16 @@
|
|||||||
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
|
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/qrcode": {
|
||||||
|
"version": "1.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||||
|
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/raf": {
|
"node_modules/@types/raf": {
|
||||||
"version": "3.4.3",
|
"version": "3.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
|
||||||
@@ -3461,7 +3472,6 @@
|
|||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -3471,7 +3481,6 @@
|
|||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-convert": "^2.0.1"
|
"color-convert": "^2.0.1"
|
||||||
@@ -3777,7 +3786,6 @@
|
|||||||
"version": "5.3.1",
|
"version": "5.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
@@ -3855,7 +3863,6 @@
|
|||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"string-width": "^4.2.0",
|
"string-width": "^4.2.0",
|
||||||
@@ -3867,7 +3874,6 @@
|
|||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-name": "~1.1.4"
|
"color-name": "~1.1.4"
|
||||||
@@ -3880,7 +3886,6 @@
|
|||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/commander": {
|
"node_modules/commander": {
|
||||||
@@ -4051,7 +4056,6 @@
|
|||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -4140,7 +4144,6 @@
|
|||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/dompurify": {
|
"node_modules/dompurify": {
|
||||||
@@ -4195,7 +4198,6 @@
|
|||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/entities": {
|
"node_modules/entities": {
|
||||||
@@ -4948,7 +4950,6 @@
|
|||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "6.* || 8.* || >= 10.*"
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
@@ -5498,7 +5499,6 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -6208,7 +6208,6 @@
|
|||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
@@ -6231,7 +6230,6 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -6376,7 +6374,6 @@
|
|||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
@@ -6458,7 +6455,6 @@
|
|||||||
"version": "1.5.4",
|
"version": "1.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dijkstrajs": "^1.0.1",
|
"dijkstrajs": "^1.0.1",
|
||||||
@@ -6653,7 +6649,6 @@
|
|||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -6673,7 +6668,6 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
@@ -6845,7 +6839,6 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/set-function-length": {
|
"node_modules/set-function-length": {
|
||||||
@@ -7113,7 +7106,6 @@
|
|||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"emoji-regex": "^8.0.0",
|
"emoji-regex": "^8.0.0",
|
||||||
@@ -7230,7 +7222,6 @@
|
|||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-regex": "^5.0.1"
|
"ansi-regex": "^5.0.1"
|
||||||
@@ -8067,7 +8058,6 @@
|
|||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/which-typed-array": {
|
"node_modules/which-typed-array": {
|
||||||
@@ -8343,7 +8333,6 @@
|
|||||||
"version": "6.2.0",
|
"version": "6.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-styles": "^4.0.0",
|
"ansi-styles": "^4.0.0",
|
||||||
@@ -8380,7 +8369,6 @@
|
|||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
@@ -8394,7 +8382,6 @@
|
|||||||
"version": "15.4.1",
|
"version": "15.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cliui": "^6.0.0",
|
"cliui": "^6.0.0",
|
||||||
@@ -8417,7 +8404,6 @@
|
|||||||
"version": "18.1.3",
|
"version": "18.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"camelcase": "^5.0.0",
|
"camelcase": "^5.0.0",
|
||||||
@@ -8431,7 +8417,6 @@
|
|||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"locate-path": "^5.0.0",
|
"locate-path": "^5.0.0",
|
||||||
@@ -8445,7 +8430,6 @@
|
|||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"p-locate": "^4.1.0"
|
"p-locate": "^4.1.0"
|
||||||
@@ -8458,7 +8442,6 @@
|
|||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"p-try": "^2.0.0"
|
"p-try": "^2.0.0"
|
||||||
@@ -8474,7 +8457,6 @@
|
|||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"p-limit": "^2.2.0"
|
"p-limit": "^2.2.0"
|
||||||
|
|||||||
@@ -29,12 +29,14 @@
|
|||||||
"lucide-react": "^1.16.0",
|
"lucide-react": "^1.16.0",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-i18next": "^17.0.8"
|
"react-i18next": "^17.0.8",
|
||||||
|
"qrcode": "^1.5.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/node": "^24.12.3",
|
"@types/node": "^24.12.3",
|
||||||
|
"@types/qrcode": "^1.5.5",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^4.7.0",
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
@@ -44,7 +46,6 @@
|
|||||||
"globals": "^17.6.0",
|
"globals": "^17.6.0",
|
||||||
"happy-dom": "^20.9.0",
|
"happy-dom": "^20.9.0",
|
||||||
"playwright": "^1.51.0",
|
"playwright": "^1.51.0",
|
||||||
"qrcode": "^1.5.4",
|
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"typescript-eslint": "^8.59.2",
|
"typescript-eslint": "^8.59.2",
|
||||||
"vite": "^6.3.5",
|
"vite": "^6.3.5",
|
||||||
|
|||||||
@@ -611,6 +611,7 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 560px;
|
max-width: 560px;
|
||||||
max-height: min(90vh, 820px);
|
max-height: min(90vh, 820px);
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feedback-modal {
|
.feedback-modal {
|
||||||
@@ -662,6 +663,182 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.registration-disclaimer.feedback-modal {
|
||||||
|
align-items: stretch;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.registration-disclaimer.feedback-modal .auth-header,
|
||||||
|
.registration-disclaimer.feedback-modal > p,
|
||||||
|
.registration-disclaimer.feedback-modal .nmea-import-summary,
|
||||||
|
.registration-disclaimer.feedback-modal .nmea-import-warning,
|
||||||
|
.registration-disclaimer.feedback-modal .nmea-import-mode,
|
||||||
|
.registration-disclaimer.feedback-modal .feedback-form__field,
|
||||||
|
.registration-disclaimer.feedback-modal .nmea-import-checkbox,
|
||||||
|
.registration-disclaimer.feedback-modal .nmea-preview-actions,
|
||||||
|
.registration-disclaimer.feedback-modal .nmea-preview-list,
|
||||||
|
.registration-disclaimer.feedback-modal .auth-actions {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nmea-import-warning {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--app-warning-text, #fcd34d);
|
||||||
|
background: var(--app-warning-bg, rgba(251, 191, 36, 0.1));
|
||||||
|
border: 1px solid var(--app-warning-border, rgba(251, 191, 36, 0.35));
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nmea-import-summary {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--app-surface-inset);
|
||||||
|
border: 1px solid var(--app-border-muted);
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nmea-import-summary p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nmea-import-summary p + p {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nmea-import-mode {
|
||||||
|
border: 1px solid var(--app-border-muted);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nmea-import-mode legend {
|
||||||
|
padding: 0 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--app-text-heading, #f1f5f9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nmea-import-mode label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nmea-import-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nmea-preview-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nmea-preview-actions .btn {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nmea-preview-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: min(45vh, 360px);
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nmea-preview-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--app-border-muted);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--app-surface-inset);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nmea-preview-row:hover {
|
||||||
|
border-color: var(--app-accent-border, rgba(212, 175, 55, 0.35));
|
||||||
|
}
|
||||||
|
|
||||||
|
.nmea-preview-row__check {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin: 2px 0 0;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
accent-color: var(--app-accent-light, #d4af37);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nmea-preview-row__body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nmea-preview-row__meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nmea-preview-time {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--app-accent-light, #d4af37);
|
||||||
|
min-width: 3.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nmea-preview-source {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(148, 163, 184, 0.15);
|
||||||
|
color: var(--app-text-muted, #94a3b8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nmea-preview-remarks {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--app-text, #e2e8f0);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
.feedback-form {
|
.feedback-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1539,6 +1716,38 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.link-with-qr {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-qr-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: var(--app-radius-card, 12px);
|
||||||
|
background: var(--app-surface-inset, rgba(0, 0, 0, 0.2));
|
||||||
|
border: 1px solid var(--app-border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-qr-label {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-qr-image {
|
||||||
|
display: block;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 8px;
|
||||||
|
box-sizing: content-box;
|
||||||
|
}
|
||||||
|
|
||||||
.form-actions--start {
|
.form-actions--start {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
@@ -1590,6 +1799,42 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
gap: 24px;
|
gap: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.logbook-card-select {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logbook-card > .card-icon,
|
||||||
|
.logbook-card > .card-info {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logbook-card > .card-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logbook-card .logbook-title-editable,
|
||||||
|
.logbook-card .logbook-title-inline-edit,
|
||||||
|
.logbook-card .card-title-row {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logbook-card--editing-title > .card-info {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.logbook-card {
|
.logbook-card {
|
||||||
background: var(--app-surface-alt);
|
background: var(--app-surface-alt);
|
||||||
backdrop-filter: var(--app-backdrop);
|
backdrop-filter: var(--app-backdrop);
|
||||||
@@ -1600,18 +1845,61 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
cursor: pointer;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.logbook-card:hover {
|
.logbook-card:hover,
|
||||||
|
.logbook-card:focus-within {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
border-color: var(--app-border);
|
border-color: var(--app-border);
|
||||||
box-shadow: var(--app-card-shadow);
|
box-shadow: var(--app-card-shadow);
|
||||||
background: var(--app-surface-hover);
|
background: var(--app-surface-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sync-conflict-banner {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: var(--app-radius-card);
|
||||||
|
border: 1px solid var(--app-warning-border, #f59e0b);
|
||||||
|
background: var(--app-warning-bg, rgba(245, 158, 11, 0.12));
|
||||||
|
color: var(--app-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-conflict-banner__body p {
|
||||||
|
margin: 4px 0 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-conflict-banner__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-persist-hint {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: var(--app-radius-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-persist-hint p {
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.logbook-card--shared {
|
.logbook-card--shared {
|
||||||
border-left: 3px solid #38bdf8;
|
border-left: 3px solid #38bdf8;
|
||||||
}
|
}
|
||||||
@@ -1662,6 +1950,8 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: -2px;
|
margin-top: -2px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logbook-card-actions .btn-delete {
|
.logbook-card-actions .btn-delete {
|
||||||
@@ -1921,9 +2211,65 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-bottom-nav {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.app-body {
|
.app-body {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
padding-bottom: calc(72px + env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-bottom-nav {
|
||||||
|
display: flex;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
justify-content: space-around;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 8px calc(8px + env(safe-area-inset-bottom, 0px));
|
||||||
|
background: var(--app-surface-alt);
|
||||||
|
backdrop-filter: var(--app-backdrop);
|
||||||
|
border-top: 1px solid var(--app-border-subtle);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav-btn {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px 4px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav-btn span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav-btn.active {
|
||||||
|
background: var(--app-sidebar-active-bg);
|
||||||
|
color: var(--app-sidebar-active-text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2990,6 +3336,482 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
color: #38bdf8;
|
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-span-2 {
|
||||||
grid-column: span 2;
|
grid-column: span 2;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import { useLiveQuery } from 'dexie-react-hooks'
|
|||||||
import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, Languages, BarChart2 } from 'lucide-react'
|
import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, Languages, BarChart2 } from 'lucide-react'
|
||||||
import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
|
import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
|
||||||
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
|
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
|
||||||
|
import ProfileHeaderButton from './components/ProfileHeaderButton.tsx'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { cycleAppLanguage } from './utils/i18nLanguages.js'
|
import { cycleAppLanguage } from './utils/i18nLanguages.js'
|
||||||
import {
|
import {
|
||||||
@@ -52,6 +53,8 @@ import {
|
|||||||
} from './services/demoLogbook.js'
|
} from './services/demoLogbook.js'
|
||||||
import { fetchLogbooks, parseCollaborationRole } from './services/logbook.js'
|
import { fetchLogbooks, parseCollaborationRole } from './services/logbook.js'
|
||||||
import { ensurePushSubscriptionIfEnabled } from './services/pushNotifications.js'
|
import { ensurePushSubscriptionIfEnabled } from './services/pushNotifications.js'
|
||||||
|
import SyncConflictBanner from './components/SyncConflictBanner.tsx'
|
||||||
|
import { requestPersistentStorage } from './utils/storagePersist.js'
|
||||||
|
|
||||||
const PENDING_PUSH_LOGBOOK_KEY = 'pending_push_logbook_id'
|
const PENDING_PUSH_LOGBOOK_KEY = 'pending_push_logbook_id'
|
||||||
|
|
||||||
@@ -70,6 +73,7 @@ function App() {
|
|||||||
const [isSyncing, setIsSyncing] = useState(false)
|
const [isSyncing, setIsSyncing] = useState(false)
|
||||||
const [isAcceptingInvite, setIsAcceptingInvite] = useState(false)
|
const [isAcceptingInvite, setIsAcceptingInvite] = useState(false)
|
||||||
const [showUserProfile, setShowUserProfile] = useState(false)
|
const [showUserProfile, setShowUserProfile] = useState(false)
|
||||||
|
const [storagePersistHint, setStoragePersistHint] = useState(false)
|
||||||
const tourLogbookRef = useRef<{ id: string; title: string } | null>(null)
|
const tourLogbookRef = useRef<{ id: string; title: string } | null>(null)
|
||||||
const activeLogbookRef = useRef<{ id: string | null; title: string | null }>({
|
const activeLogbookRef = useRef<{ id: string | null; title: string | null }>({
|
||||||
id: activeLogbookId,
|
id: activeLogbookId,
|
||||||
@@ -427,10 +431,19 @@ function App() {
|
|||||||
return () => navigator.serviceWorker.removeEventListener('message', onSwMessage)
|
return () => navigator.serviceWorker.removeEventListener('message', onSwMessage)
|
||||||
}, [isAuthenticated, openLogbookById])
|
}, [isAuthenticated, openLogbookById])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) return
|
||||||
|
if (sessionStorage.getItem('storage_persist_hint_dismissed')) return
|
||||||
|
void requestPersistentStorage().then(({ persisted, supported }) => {
|
||||||
|
if (supported && !persisted) setStoragePersistHint(true)
|
||||||
|
})
|
||||||
|
}, [isAuthenticated])
|
||||||
|
|
||||||
const handleAuthenticated = async () => {
|
const handleAuthenticated = async () => {
|
||||||
setIsAuthenticated(true)
|
setIsAuthenticated(true)
|
||||||
trackPlausibleEvent(PlausibleEvents.LOGGED_IN)
|
trackPlausibleEvent(PlausibleEvents.LOGGED_IN)
|
||||||
void ensurePushSubscriptionIfEnabled()
|
void ensurePushSubscriptionIfEnabled()
|
||||||
|
void requestPersistentStorage()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const demo = await seedDemoLogbookIfNeeded()
|
const demo = await seedDemoLogbookIfNeeded()
|
||||||
@@ -557,22 +570,27 @@ function App() {
|
|||||||
const isLogbookOwner =
|
const isLogbookOwner =
|
||||||
activeAccessRole === 'OWNER' || activeLogbookRecord?.isShared !== 1
|
activeAccessRole === 'OWNER' || activeLogbookRecord?.isShared !== 1
|
||||||
|
|
||||||
if (!activeLogbookId) {
|
if (showUserProfile) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'contents' }}>
|
<div style={{ display: 'contents' }}>
|
||||||
{pwaInstallBanner}
|
{pwaInstallBanner}
|
||||||
{showUserProfile ? (
|
|
||||||
<UserProfilePage
|
<UserProfilePage
|
||||||
onBack={() => setShowUserProfile(false)}
|
onBack={() => setShowUserProfile(false)}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
/>
|
/>
|
||||||
) : (
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!activeLogbookId) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'contents' }}>
|
||||||
|
{pwaInstallBanner}
|
||||||
<LogbookDashboard
|
<LogbookDashboard
|
||||||
onSelectLogbook={selectLogbook}
|
onSelectLogbook={selectLogbook}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
onOpenProfile={() => setShowUserProfile(true)}
|
onOpenProfile={() => setShowUserProfile(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -600,7 +618,7 @@ function App() {
|
|||||||
<p className="app-subtitle">
|
<p className="app-subtitle">
|
||||||
{activeAccessRole && activeAccessRole !== 'OWNER'
|
{activeAccessRole && activeAccessRole !== 'OWNER'
|
||||||
? t('dashboard.section_shared_hint')
|
? t('dashboard.section_shared_hint')
|
||||||
: `${t('app.name')} / ${activeLogbookId?.substring(0, 8)}...`}
|
: t('app.tagline')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -622,6 +640,8 @@ function App() {
|
|||||||
<Languages size={18} />
|
<Languages size={18} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<ProfileHeaderButton onClick={() => setShowUserProfile(true)} />
|
||||||
|
|
||||||
<DisclaimerHeaderButton />
|
<DisclaimerHeaderButton />
|
||||||
|
|
||||||
<FeedbackHeaderButton
|
<FeedbackHeaderButton
|
||||||
@@ -638,10 +658,28 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<SyncConflictBanner logbookId={activeLogbookId} />
|
||||||
|
|
||||||
|
{storagePersistHint && (
|
||||||
|
<div className="storage-persist-hint glass" role="status">
|
||||||
|
<p>{t('pwa.storage_persist_hint')}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={() => {
|
||||||
|
sessionStorage.setItem('storage_persist_hint_dismissed', '1')
|
||||||
|
setStoragePersistHint(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('pwa.later')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Active Workspace */}
|
{/* Active Workspace */}
|
||||||
<div className="app-body">
|
<div className="app-body">
|
||||||
{/* Navigation Sidebar */}
|
{/* Navigation Sidebar */}
|
||||||
<aside className="app-sidebar">
|
<aside className="app-sidebar" aria-label={t('nav.dashboard')}>
|
||||||
<button
|
<button
|
||||||
className={`sidebar-btn ${activeTab === 'logs' ? 'active' : ''}`}
|
className={`sidebar-btn ${activeTab === 'logs' ? 'active' : ''}`}
|
||||||
onClick={() => void handleTabChange('logs')}
|
onClick={() => void handleTabChange('logs')}
|
||||||
@@ -738,6 +776,53 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<nav className="app-bottom-nav" aria-label={t('nav.dashboard')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`bottom-nav-btn ${activeTab === 'logs' ? 'active' : ''}`}
|
||||||
|
onClick={() => void handleTabChange('logs')}
|
||||||
|
data-tour="nav-logs"
|
||||||
|
>
|
||||||
|
<FileText size={20} />
|
||||||
|
<span>{t('nav.logs')}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`bottom-nav-btn ${activeTab === 'vessel' ? 'active' : ''}`}
|
||||||
|
onClick={() => void handleTabChange('vessel')}
|
||||||
|
data-tour="nav-vessel"
|
||||||
|
>
|
||||||
|
<Ship size={20} />
|
||||||
|
<span>{t('nav.vessel')}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`bottom-nav-btn ${activeTab === 'crew' ? 'active' : ''}`}
|
||||||
|
onClick={() => void handleTabChange('crew')}
|
||||||
|
data-tour="nav-crew"
|
||||||
|
>
|
||||||
|
<Users size={20} />
|
||||||
|
<span>{t('nav.crew')}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`bottom-nav-btn ${activeTab === 'stats' ? 'active' : ''}`}
|
||||||
|
onClick={() => void handleTabChange('stats')}
|
||||||
|
data-tour="nav-stats"
|
||||||
|
>
|
||||||
|
<BarChart2 size={20} />
|
||||||
|
<span>{t('nav.stats')}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`bottom-nav-btn ${activeTab === 'settings' ? 'active' : ''}`}
|
||||||
|
onClick={() => void handleTabChange('settings')}
|
||||||
|
>
|
||||||
|
<Settings size={20} />
|
||||||
|
<span>{t('nav.settings')}</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,12 +10,19 @@ import {
|
|||||||
decryptWithLocalPin,
|
decryptWithLocalPin,
|
||||||
getActiveMasterKey,
|
getActiveMasterKey,
|
||||||
getKnownUsernames,
|
getKnownUsernames,
|
||||||
forgetUsername
|
forgetUsername,
|
||||||
|
hasUnlockedLocalSession,
|
||||||
|
logoutUser
|
||||||
} from '../services/auth.js'
|
} from '../services/auth.js'
|
||||||
import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react'
|
import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react'
|
||||||
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
|
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
|
||||||
import DisclaimerModal from './DisclaimerModal.tsx'
|
import DisclaimerModal from './DisclaimerModal.tsx'
|
||||||
import BetaBadge from './BetaBadge.tsx'
|
import BetaBadge from './BetaBadge.tsx'
|
||||||
|
import {
|
||||||
|
isPasskeyCompatibleLocation,
|
||||||
|
localizeWebAuthnError,
|
||||||
|
toPasskeyCompatibleUrl
|
||||||
|
} from '../utils/passkeyHost.ts'
|
||||||
|
|
||||||
interface AuthOnboardingProps {
|
interface AuthOnboardingProps {
|
||||||
onAuthenticated: () => void
|
onAuthenticated: () => void
|
||||||
@@ -54,6 +61,16 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
|||||||
const [showDisclaimer, setShowDisclaimer] = useState(false)
|
const [showDisclaimer, setShowDisclaimer] = useState(false)
|
||||||
const [showHelp, setShowHelp] = useState(false)
|
const [showHelp, setShowHelp] = useState(false)
|
||||||
|
|
||||||
|
const passkeyHostOk = isPasskeyCompatibleLocation()
|
||||||
|
const passkeyCompatibleUrl = passkeyHostOk ? null : toPasskeyCompatibleUrl(window.location.href)
|
||||||
|
|
||||||
|
const formatAuthError = (message: string) =>
|
||||||
|
localizeWebAuthnError(message, {
|
||||||
|
invalidHost: t('auth.error_invalid_host'),
|
||||||
|
cancelled: t('auth.error_passkey_cancelled'),
|
||||||
|
invalidRpId: t('auth.error_invalid_rp_id')
|
||||||
|
})
|
||||||
|
|
||||||
const finishAuth = () => {
|
const finishAuth = () => {
|
||||||
if (isNewRegistration) {
|
if (isNewRegistration) {
|
||||||
setShowDisclaimer(true)
|
setShowDisclaimer(true)
|
||||||
@@ -81,7 +98,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
|||||||
setRecoveryPhrase(result.recoveryPhrase)
|
setRecoveryPhrase(result.recoveryPhrase)
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Registration failed')
|
setError(formatAuthError(err.message || 'Registration failed'))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -121,7 +138,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Login failed')
|
setError(formatAuthError(err.message || 'Login failed'))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -185,19 +202,33 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
|||||||
|
|
||||||
const handlePinLoginSubmit = async (e: React.FormEvent) => {
|
const handlePinLoginSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!pinLoginInput.trim()) return
|
if (!pinLoginInput.trim() || loading) return
|
||||||
|
|
||||||
|
const resolvedUser =
|
||||||
|
username.trim() ||
|
||||||
|
encryptedPayloads?.username ||
|
||||||
|
localStorage.getItem('active_username') ||
|
||||||
|
''
|
||||||
|
if (!resolvedUser) {
|
||||||
|
setError(t('auth.error_session_incomplete'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const resolvedUser = username.trim() || encryptedPayloads?.username
|
|
||||||
const key = await decryptWithLocalPin(pinLoginInput.trim(), resolvedUser)
|
const key = await decryptWithLocalPin(pinLoginInput.trim(), resolvedUser)
|
||||||
if (key) {
|
if (!key) {
|
||||||
onAuthenticated()
|
|
||||||
} else {
|
|
||||||
setError(t('auth.error_incorrect_pin'))
|
setError(t('auth.error_incorrect_pin'))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
if (!hasUnlockedLocalSession()) {
|
||||||
|
setError(t('auth.error_session_incomplete'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setShowPinLogin(false)
|
||||||
|
onAuthenticated()
|
||||||
|
} catch {
|
||||||
setError(t('auth.error_incorrect_pin'))
|
setError(t('auth.error_incorrect_pin'))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -361,6 +392,24 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
|||||||
>
|
>
|
||||||
{t('auth.use_recovery_instead')}
|
{t('auth.use_recovery_instead')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={() => {
|
||||||
|
void (async () => {
|
||||||
|
setShowPinLogin(false)
|
||||||
|
setPinLoginInput('')
|
||||||
|
setEncryptedPayloads(null)
|
||||||
|
setError(null)
|
||||||
|
await logoutUser()
|
||||||
|
})()
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
{t('auth.back')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -445,12 +494,21 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="auth-form" style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
<div className="auth-form" style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||||
|
{!passkeyHostOk && passkeyCompatibleUrl && (
|
||||||
|
<div className="auth-error" role="alert">
|
||||||
|
<p style={{ margin: '0 0 8px' }}>{t('auth.error_invalid_host')}</p>
|
||||||
|
<a href={passkeyCompatibleUrl} className="btn secondary" style={{ display: 'inline-block', textDecoration: 'none' }}>
|
||||||
|
{t('auth.use_localhost_link')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Prominent Login button */}
|
{/* Prominent Login button */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn primary"
|
className="btn primary"
|
||||||
onClick={() => handleLogin()}
|
onClick={() => handleLogin()}
|
||||||
disabled={loading}
|
disabled={loading || !passkeyHostOk}
|
||||||
style={{ width: '100%', padding: '16px' }}
|
style={{ width: '100%', padding: '16px' }}
|
||||||
>
|
>
|
||||||
{loading
|
{loading
|
||||||
@@ -583,7 +641,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn secondary"
|
className="btn secondary"
|
||||||
disabled={loading || !username.trim()}
|
disabled={loading || !username.trim() || !passkeyHostOk}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
>
|
>
|
||||||
{t('auth.register')}
|
{t('auth.register')}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,11 +8,13 @@ import { syncLogbook } from '../services/sync.js'
|
|||||||
import { downloadCsv, shareCsv } from '../services/csvExport.js'
|
import { downloadCsv, shareCsv } from '../services/csvExport.js'
|
||||||
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
|
import { getErrorMessage } from '../utils/errors.js'
|
||||||
import LogEntryEditor from './LogEntryEditor.tsx'
|
import LogEntryEditor from './LogEntryEditor.tsx'
|
||||||
|
import LiveLogView from './LiveLogView.tsx'
|
||||||
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
|
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
|
||||||
import { useDialog } from './ModalDialog.tsx'
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
import { getSkipperSignStatus, type SkipperSignStatus } from '../utils/signatures.js'
|
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 {
|
import {
|
||||||
carryOverFromPreviousDay,
|
carryOverFromPreviousDay,
|
||||||
compareTravelDaysChronological,
|
compareTravelDaysChronological,
|
||||||
@@ -36,6 +38,8 @@ interface LogEntriesListProps {
|
|||||||
highlightEntryId?: string | null
|
highlightEntryId?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LogsViewMode = 'list' | 'live'
|
||||||
|
|
||||||
interface DecryptedEntryItem {
|
interface DecryptedEntryItem {
|
||||||
id: string
|
id: string
|
||||||
date: string
|
date: string
|
||||||
@@ -75,6 +79,8 @@ export default function LogEntriesList({
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [exporting, setExporting] = useState(false)
|
const [exporting, setExporting] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
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 prevSelectedEntryIdRef = useRef<string | null | undefined>(undefined)
|
||||||
|
|
||||||
const loadEntries = useCallback(async () => {
|
const loadEntries = useCallback(async () => {
|
||||||
@@ -137,24 +143,26 @@ export default function LogEntriesList({
|
|||||||
setEntries(list)
|
setEntries(list)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to load log entries:', err)
|
console.error('Failed to load log entries:', err)
|
||||||
setError(err.message || 'Decryption failed. Could not load journal list.')
|
setError(getErrorMessage(err, t('errors.load_failed')))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [logbookId, readOnly, preloadedEntries])
|
}, [logbookId, readOnly, preloadedEntries])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (viewMode === 'live') return
|
||||||
loadEntries()
|
loadEntries()
|
||||||
}, [loadEntries])
|
}, [loadEntries, viewMode])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (viewMode === 'live') return
|
||||||
const prevSelectedEntryId = prevSelectedEntryIdRef.current
|
const prevSelectedEntryId = prevSelectedEntryIdRef.current
|
||||||
prevSelectedEntryIdRef.current = selectedEntryId
|
prevSelectedEntryIdRef.current = selectedEntryId
|
||||||
|
|
||||||
if (prevSelectedEntryId !== undefined && prevSelectedEntryId !== null && selectedEntryId === null) {
|
if (prevSelectedEntryId !== undefined && prevSelectedEntryId !== null && selectedEntryId === null) {
|
||||||
loadEntries()
|
loadEntries()
|
||||||
}
|
}
|
||||||
}, [selectedEntryId, loadEntries])
|
}, [selectedEntryId, loadEntries, viewMode])
|
||||||
|
|
||||||
const handleDownloadCsv = async () => {
|
const handleDownloadCsv = async () => {
|
||||||
setExporting(true)
|
setExporting(true)
|
||||||
@@ -169,7 +177,7 @@ export default function LogEntriesList({
|
|||||||
trackPlausibleEvent(PlausibleEvents.CSV_EXPORTED)
|
trackPlausibleEvent(PlausibleEvents.CSV_EXPORTED)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to download CSV:', err)
|
console.error('Failed to download CSV:', err)
|
||||||
setError(err.message || 'Failed to generate CSV export.')
|
setError(getErrorMessage(err, t('errors.export_failed')))
|
||||||
} finally {
|
} finally {
|
||||||
setExporting(false)
|
setExporting(false)
|
||||||
}
|
}
|
||||||
@@ -197,7 +205,7 @@ export default function LogEntriesList({
|
|||||||
setError(t('logs.share_unsupported'))
|
setError(t('logs.share_unsupported'))
|
||||||
} else {
|
} else {
|
||||||
console.error('Failed to share CSV:', err)
|
console.error('Failed to share CSV:', err)
|
||||||
setError(err.message || 'Failed to share CSV export.')
|
setError(getErrorMessage(err, t('errors.export_failed')))
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setExporting(false)
|
setExporting(false)
|
||||||
@@ -218,7 +226,7 @@ export default function LogEntriesList({
|
|||||||
trackPlausibleEvent(PlausibleEvents.PDF_EXPORTED, { scope: 'entry' })
|
trackPlausibleEvent(PlausibleEvents.PDF_EXPORTED, { scope: 'entry' })
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to download PDF:', err)
|
console.error('Failed to download PDF:', err)
|
||||||
setError(err.message || 'Failed to generate PDF export.')
|
setError(getErrorMessage(err, t('errors.export_failed')))
|
||||||
} finally {
|
} finally {
|
||||||
setExporting(false)
|
setExporting(false)
|
||||||
}
|
}
|
||||||
@@ -310,7 +318,7 @@ export default function LogEntriesList({
|
|||||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to create entry:', err)
|
console.error('Failed to create entry:', err)
|
||||||
setError(err.message || 'Failed to create new log entry.')
|
setError(getErrorMessage(err, t('errors.save_failed')))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -340,7 +348,7 @@ export default function LogEntriesList({
|
|||||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to delete log entry:', err)
|
console.error('Failed to delete log entry:', err)
|
||||||
setError(err.message || 'Failed to delete log entry.')
|
setError(getErrorMessage(err, t('errors.delete_failed')))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -350,7 +358,13 @@ export default function LogEntriesList({
|
|||||||
<LogEntryEditor
|
<LogEntryEditor
|
||||||
entryId={selectedEntryId}
|
entryId={selectedEntryId}
|
||||||
logbookId={logbookId}
|
logbookId={logbookId}
|
||||||
onBack={() => setSelectedEntryId(null)}
|
onBack={() => {
|
||||||
|
setSelectedEntryId(null)
|
||||||
|
if (returnToLiveAfterEditor) {
|
||||||
|
setViewMode('live')
|
||||||
|
setReturnToLiveAfterEditor(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
preloadedEntry={preloadedEntries?.find(entry => (entry.payloadId || entry.id) === selectedEntryId)}
|
preloadedEntry={preloadedEntries?.find(entry => (entry.payloadId || entry.id) === selectedEntryId)}
|
||||||
preloadedPhotos={preloadedPhotos}
|
preloadedPhotos={preloadedPhotos}
|
||||||
@@ -359,6 +373,19 @@ export default function LogEntriesList({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (viewMode === 'live' && !readOnly) {
|
||||||
|
return (
|
||||||
|
<LiveLogView
|
||||||
|
logbookId={logbookId}
|
||||||
|
onOpenEditor={(entryId) => {
|
||||||
|
setReturnToLiveAfterEditor(true)
|
||||||
|
setSelectedEntryId(entryId)
|
||||||
|
}}
|
||||||
|
onSwitchToList={() => setViewMode('list')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="tab-placeholder">
|
<div className="tab-placeholder">
|
||||||
@@ -381,6 +408,29 @@ export default function LogEntriesList({
|
|||||||
<h2>{t('logs.title')}</h2>
|
<h2>{t('logs.title')}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="section-toolbar">
|
<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')}>
|
<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} />
|
<Download size={16} />
|
||||||
<span className="hide-mobile">{exporting ? t('logs.exporting') : t('logs.export_csv')}</span>
|
<span className="hide-mobile">{exporting ? t('logs.exporting') : t('logs.export_csv')}</span>
|
||||||
@@ -411,6 +461,10 @@ export default function LogEntriesList({
|
|||||||
key={item.id}
|
key={item.id}
|
||||||
className="logbook-card glass"
|
className="logbook-card glass"
|
||||||
data-tour={tourFirstEntryId === item.id ? 'entry-first' : undefined}
|
data-tour={tourFirstEntryId === item.id ? 'entry-first' : undefined}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="logbook-card-select"
|
||||||
onClick={() => setSelectedEntryId(item.id)}
|
onClick={() => setSelectedEntryId(item.id)}
|
||||||
>
|
>
|
||||||
<div className="card-icon">
|
<div className="card-icon">
|
||||||
@@ -425,7 +479,7 @@ export default function LogEntriesList({
|
|||||||
</h3>
|
</h3>
|
||||||
<div className="card-meta">
|
<div className="card-meta">
|
||||||
<span className="sync-badge synced">
|
<span className="sync-badge synced">
|
||||||
{t('logs.day_of_travel')} {item.dayOfTravel}
|
{t('logs.travel_day_number', { number: item.dayOfTravel })}
|
||||||
</span>
|
</span>
|
||||||
<EntrySkipperSignBadge status={item.skipperSignStatus} />
|
<EntrySkipperSignBadge status={item.skipperSignStatus} />
|
||||||
<span className="date-badge">
|
<span className="date-badge">
|
||||||
@@ -434,6 +488,9 @@ export default function LogEntriesList({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ChevronRight size={18} style={{ color: '#475569', marginLeft: 'auto' }} aria-hidden />
|
||||||
|
</button>
|
||||||
|
|
||||||
<button className="btn-pdf" onClick={(e) => handleDownloadPdf(item.id, item.date, e)} title={t('logs.export_pdf')} disabled={exporting}>
|
<button className="btn-pdf" onClick={(e) => handleDownloadPdf(item.id, item.date, e)} title={t('logs.export_pdf')} disabled={exporting}>
|
||||||
<Download size={18} />
|
<Download size={18} />
|
||||||
</button>
|
</button>
|
||||||
@@ -443,8 +500,6 @@ export default function LogEntriesList({
|
|||||||
<Trash2 size={18} />
|
<Trash2 size={18} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ChevronRight size={18} style={{ color: '#475569', marginLeft: 'auto' }} />
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { getActiveMasterKey } from '../services/auth.js'
|
|||||||
import { getLogbookKey } from '../services/logbookKeys.js'
|
import { getLogbookKey } from '../services/logbookKeys.js'
|
||||||
import { encryptJson, decryptJson } from '../services/crypto.js'
|
import { encryptJson, decryptJson } from '../services/crypto.js'
|
||||||
import { syncLogbook } from '../services/sync.js'
|
import { syncLogbook } from '../services/sync.js'
|
||||||
|
import { saveEntryDraft, clearEntryDraft } from '../services/entryDraft.js'
|
||||||
|
import { getErrorMessage } from '../utils/errors.js'
|
||||||
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
||||||
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X, ChevronDown, ChevronUp } from 'lucide-react'
|
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X, ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
import PhotoCapture from './PhotoCapture.tsx'
|
import PhotoCapture from './PhotoCapture.tsx'
|
||||||
@@ -25,7 +27,7 @@ import type { SignatureValue } from '../types/signatures.js'
|
|||||||
import { buildLogEntryPayload, sortLogEventsByTime, normalizeLogEvent, hasUnsavedEventDraft, currentLocalTimeHHMM, isValidTimeHHMM, type LogEventPayload } from '../utils/logEntryPayload.js'
|
import { buildLogEntryPayload, sortLogEventsByTime, normalizeLogEvent, hasUnsavedEventDraft, currentLocalTimeHHMM, isValidTimeHHMM, type LogEventPayload } from '../utils/logEntryPayload.js'
|
||||||
import EventTimeInput24h from './EventTimeInput24h.tsx'
|
import EventTimeInput24h from './EventTimeInput24h.tsx'
|
||||||
import CourseDialInput from './CourseDialInput.tsx'
|
import CourseDialInput from './CourseDialInput.tsx'
|
||||||
import { degreesToCardinal } from '../utils/courseAngle.js'
|
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
|
||||||
import { hashEntryForSigning } from '../utils/entryCanonicalHash.js'
|
import { hashEntryForSigning } from '../utils/entryCanonicalHash.js'
|
||||||
import { signLogEntry } from '../services/entrySigning.js'
|
import { signLogEntry } from '../services/entrySigning.js'
|
||||||
import { getLogbookAccess } from '../services/logbookAccess.js'
|
import { getLogbookAccess } from '../services/logbookAccess.js'
|
||||||
@@ -37,8 +39,16 @@ import {
|
|||||||
deleteTrack,
|
deleteTrack,
|
||||||
downloadTrackFile,
|
downloadTrackFile,
|
||||||
parseTrackFile,
|
parseTrackFile,
|
||||||
type SavedTrack
|
type SavedTrack,
|
||||||
|
type TrackWaypoint
|
||||||
} from '../services/trackUpload.js'
|
} from '../services/trackUpload.js'
|
||||||
|
import NmeaImportWizard from './NmeaImportWizard.tsx'
|
||||||
|
import {
|
||||||
|
deleteNmeaArchive,
|
||||||
|
downloadNmeaArchive,
|
||||||
|
getNmeaArchive,
|
||||||
|
type NmeaArchiveRecord
|
||||||
|
} from '../services/nmeaArchive.js'
|
||||||
import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
|
import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
|
||||||
import { computeFuelPerMotorHour, formatFuelPerMotorHour } from '../utils/fuelStats.js'
|
import { computeFuelPerMotorHour, formatFuelPerMotorHour } from '../utils/fuelStats.js'
|
||||||
import { useRegisterUnsavedChanges } from '../context/UnsavedChangesContext.tsx'
|
import { useRegisterUnsavedChanges } from '../context/UnsavedChangesContext.tsx'
|
||||||
@@ -210,6 +220,8 @@ export default function LogEntryEditor({
|
|||||||
const [savedTrack, setSavedTrack] = useState<SavedTrack | null>(null)
|
const [savedTrack, setSavedTrack] = useState<SavedTrack | null>(null)
|
||||||
const [dragOver, setDragOver] = useState(false)
|
const [dragOver, setDragOver] = useState(false)
|
||||||
const [uploadError, setUploadError] = useState<string | null>(null)
|
const [uploadError, setUploadError] = useState<string | null>(null)
|
||||||
|
const [nmeaWizardOpen, setNmeaWizardOpen] = useState(false)
|
||||||
|
const [nmeaArchive, setNmeaArchive] = useState<NmeaArchiveRecord | null>(null)
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
const lockedContentHashRef = useRef<string | null>(null)
|
const lockedContentHashRef = useRef<string | null>(null)
|
||||||
const contentReadyRef = useRef(false)
|
const contentReadyRef = useRef(false)
|
||||||
@@ -278,6 +290,14 @@ export default function LogEntryEditor({
|
|||||||
events
|
events
|
||||||
])
|
])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (readOnly || loading || !date) return
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
void saveEntryDraft(logbookId, entryId, buildPayloadForSigning())
|
||||||
|
}, 4000)
|
||||||
|
return () => window.clearTimeout(timer)
|
||||||
|
}, [readOnly, loading, logbookId, entryId, buildPayloadForSigning, date])
|
||||||
|
|
||||||
const fuelPerMotorHour = useMemo(
|
const fuelPerMotorHour = useMemo(
|
||||||
() => computeFuelPerMotorHour(parseFloat(fuelConsumption) || 0, parseFloat(motorHours) || 0),
|
() => computeFuelPerMotorHour(parseFloat(fuelConsumption) || 0, parseFloat(motorHours) || 0),
|
||||||
[fuelConsumption, motorHours]
|
[fuelConsumption, motorHours]
|
||||||
@@ -762,6 +782,45 @@ export default function LogEntryEditor({
|
|||||||
loadTrack()
|
loadTrack()
|
||||||
}, [entryId, preloadedTrack])
|
}, [entryId, preloadedTrack])
|
||||||
|
|
||||||
|
const loadNmeaArchive = async () => {
|
||||||
|
if (readOnly) return
|
||||||
|
try {
|
||||||
|
const archive = await getNmeaArchive(entryId)
|
||||||
|
setNmeaArchive(archive)
|
||||||
|
} catch {
|
||||||
|
setNmeaArchive(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadNmeaArchive()
|
||||||
|
}, [entryId, readOnly])
|
||||||
|
|
||||||
|
const handleNmeaImport = async (importedEvents: LogEventPayload[], waypoints?: TrackWaypoint[]) => {
|
||||||
|
setEvents((prev) => sortLogEventsByTime([...prev, ...importedEvents]))
|
||||||
|
if (waypoints && waypoints.length > 0) {
|
||||||
|
try {
|
||||||
|
const gpxLike = waypoints
|
||||||
|
.map((wp) => ` <trkpt lat="${wp.lat}" lon="${wp.lng}"><time>${new Date(wp.timestamp).toISOString()}</time></trkpt>`)
|
||||||
|
.join('\n')
|
||||||
|
const content = `<?xml version="1.0"?><gpx><trk><trkseg>\n${gpxLike}\n</trkseg></trk></gpx>`
|
||||||
|
await saveUploadedTrack(logbookId, entryId, content, waypoints, 'imported-from-nmea.nmea', 'nmea')
|
||||||
|
applyTrackStats(waypoints)
|
||||||
|
await loadTrack()
|
||||||
|
trackPlausibleEvent(PlausibleEvents.GPS_TRACK_UPLOADED)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.warn('Failed to save NMEA track:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await loadNmeaArchive()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteNmeaArchive = async () => {
|
||||||
|
if (!window.confirm(t('logs.nmea_archive_delete_confirm'))) return
|
||||||
|
await deleteNmeaArchive(entryId)
|
||||||
|
setNmeaArchive(null)
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!savedTrack || savedTrack.waypoints.length < 2) return
|
if (!savedTrack || savedTrack.waypoints.length < 2) return
|
||||||
if (trackDistanceNm || trackSpeedMaxKn || trackSpeedAvgKn) return
|
if (trackDistanceNm || trackSpeedMaxKn || trackSpeedAvgKn) return
|
||||||
@@ -851,7 +910,10 @@ export default function LogEntryEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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
|
const coord = data.coord as { lat?: number; lon?: number } | undefined
|
||||||
if (coord?.lat !== undefined && coord?.lon !== undefined) {
|
if (coord?.lat !== undefined && coord?.lon !== undefined) {
|
||||||
setEvGpsLat(Number(coord.lat).toFixed(6))
|
setEvGpsLat(Number(coord.lat).toFixed(6))
|
||||||
@@ -906,7 +968,8 @@ export default function LogEntryEditor({
|
|||||||
const data = await fetchOpenWeatherCurrent(
|
const data = await fetchOpenWeatherCurrent(
|
||||||
hasGps
|
hasGps
|
||||||
? { lat: evGpsLat, lon: evGpsLng }
|
? { lat: evGpsLat, lon: evGpsLng }
|
||||||
: { q: fallbackLocation }
|
: { q: fallbackLocation },
|
||||||
|
{ analyticsSource: 'entry_editor' }
|
||||||
)
|
)
|
||||||
|
|
||||||
const coord = data.coord as { lat?: number; lon?: number } | undefined
|
const coord = data.coord as { lat?: number; lon?: number } | undefined
|
||||||
@@ -916,38 +979,11 @@ export default function LogEntryEditor({
|
|||||||
setEvGpsLng(Number(coord.lon).toFixed(6))
|
setEvGpsLng(Number(coord.lon).toFixed(6))
|
||||||
}
|
}
|
||||||
|
|
||||||
const wind = data.wind as { speed?: number; deg?: number } | undefined
|
const parsed = parseOwmCurrentWeather(data)
|
||||||
const main = data.main as { pressure?: number } | undefined
|
setEvWindStrength(parsed.windStrength)
|
||||||
|
setEvWindPressure(parsed.windPressure)
|
||||||
// Convert wind speed m/s to Beaufort scale
|
if (parsed.windDirection) setEvWindDirection(parsed.windDirection)
|
||||||
const mps = wind?.speed || 0
|
if (parsed.weatherIcon) setEvWeatherIcon(parsed.weatherIcon)
|
||||||
let bft = 0
|
|
||||||
if (mps < 0.3) bft = 0
|
|
||||||
else if (mps < 1.6) bft = 1
|
|
||||||
else if (mps < 3.4) bft = 2
|
|
||||||
else if (mps < 5.5) bft = 3
|
|
||||||
else if (mps < 8.0) bft = 4
|
|
||||||
else if (mps < 10.8) bft = 5
|
|
||||||
else if (mps < 13.9) bft = 6
|
|
||||||
else if (mps < 17.2) bft = 7
|
|
||||||
else if (mps < 20.8) bft = 8
|
|
||||||
else if (mps < 24.5) bft = 9
|
|
||||||
else if (mps < 28.5) bft = 10
|
|
||||||
else if (mps < 32.7) bft = 11
|
|
||||||
else bft = 12
|
|
||||||
|
|
||||||
setEvWindStrength(`${bft} Bft (${mps.toFixed(1)} m/s)`)
|
|
||||||
setEvWindPressure(String(main?.pressure || ''))
|
|
||||||
|
|
||||||
// Calculate wind compass direction sector
|
|
||||||
if (wind?.deg !== undefined) {
|
|
||||||
setEvWindDirection(degreesToCardinal(wind.deg))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.weather && Array.isArray(data.weather) && data.weather[0]) {
|
|
||||||
const first = data.weather[0] as { icon?: string }
|
|
||||||
if (first.icon) setEvWeatherIcon(first.icon)
|
|
||||||
}
|
|
||||||
|
|
||||||
showAlert(t('settings.weather_success'))
|
showAlert(t('settings.weather_success'))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1182,15 +1218,17 @@ export default function LogEntryEditor({
|
|||||||
...signaturesForSave
|
...signaturesForSave
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await clearEntryDraft(logbookId, entryId)
|
||||||
|
|
||||||
setSuccess(true)
|
setSuccess(true)
|
||||||
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
|
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setSuccess(false)
|
setSuccess(false)
|
||||||
onBack()
|
onBack()
|
||||||
}, 1500)
|
}, 1500)
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
console.error('Failed to save entry details:', err)
|
console.error('Failed to save entry details:', err)
|
||||||
setError(err.message || 'Failed to save entry details.')
|
setError(getErrorMessage(err, t('errors.save_failed')))
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
@@ -1925,6 +1963,31 @@ export default function LogEntryEditor({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!readOnly && (
|
||||||
|
<div className="nmea-import-section" style={{ marginTop: '12px' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={() => setNmeaWizardOpen(true)}
|
||||||
|
style={{ width: 'auto', padding: '8px 14px', display: 'inline-flex', alignItems: 'center', gap: '6px' }}
|
||||||
|
>
|
||||||
|
<FileText size={16} />
|
||||||
|
{t('logs.nmea_import_btn')}
|
||||||
|
</button>
|
||||||
|
{nmeaArchive && (
|
||||||
|
<div className="nmea-archive-info" style={{ marginTop: '8px', display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
<span>{t('logs.nmea_archive_stored', { name: nmeaArchive.filename })}</span>
|
||||||
|
<button type="button" className="btn secondary" style={{ width: 'auto', padding: '4px 10px', fontSize: '13px' }} onClick={() => downloadNmeaArchive(nmeaArchive)}>
|
||||||
|
<Download size={14} />
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn secondary" style={{ width: 'auto', padding: '4px 10px', fontSize: '13px' }} onClick={handleDeleteNmeaArchive}>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{(savedTrack || trackDistanceNm || trackSpeedMaxKn || trackSpeedAvgKn) && (
|
{(savedTrack || trackDistanceNm || trackSpeedMaxKn || trackSpeedAvgKn) && (
|
||||||
<div className="form-grid track-stats-grid">
|
<div className="form-grid track-stats-grid">
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
@@ -2030,6 +2093,19 @@ export default function LogEntryEditor({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<NmeaImportWizard
|
||||||
|
open={nmeaWizardOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setNmeaWizardOpen(false)
|
||||||
|
void loadNmeaArchive()
|
||||||
|
}}
|
||||||
|
logbookId={logbookId}
|
||||||
|
entryId={entryId}
|
||||||
|
entryDate={date}
|
||||||
|
nmeaArchive={nmeaArchive}
|
||||||
|
onImport={handleNmeaImport}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type D
|
|||||||
import LogbookRoleBadge from './LogbookRoleBadge.tsx'
|
import LogbookRoleBadge from './LogbookRoleBadge.tsx'
|
||||||
import BetaBadge from './BetaBadge.tsx'
|
import BetaBadge from './BetaBadge.tsx'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
|
import { getErrorMessage } from '../utils/errors.js'
|
||||||
import { logoutUser } from '../services/auth.js'
|
import { logoutUser } from '../services/auth.js'
|
||||||
import { useDialog } from './ModalDialog.tsx'
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, User, Wifi, WifiOff, Search, X, CalendarDays, CaseSensitive, ArrowUp, ArrowDown } from 'lucide-react'
|
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, Wifi, WifiOff, Search, X, CalendarDays, CaseSensitive, ArrowUp, ArrowDown } from 'lucide-react'
|
||||||
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
|
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
|
||||||
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
|
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
|
||||||
|
import ProfileHeaderButton from './ProfileHeaderButton.tsx'
|
||||||
|
|
||||||
interface LogbookDashboardProps {
|
interface LogbookDashboardProps {
|
||||||
onSelectLogbook: (id: string, title: string) => void
|
onSelectLogbook: (id: string, title: string) => void
|
||||||
@@ -74,7 +76,6 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
const [sortDirection, setSortDirection] = useState<LogbookSortDirection>('desc')
|
const [sortDirection, setSortDirection] = useState<LogbookSortDirection>('desc')
|
||||||
const filterInputRef = useRef<HTMLInputElement>(null)
|
const filterInputRef = useRef<HTMLInputElement>(null)
|
||||||
const [online, setOnline] = useState(navigator.onLine)
|
const [online, setOnline] = useState(navigator.onLine)
|
||||||
const [username] = useState(localStorage.getItem('active_username') || 'Skipper')
|
|
||||||
|
|
||||||
const { pendingCount, showSpinner, showPendingWarning, connStatusClassName } = useSyncIndicator()
|
const { pendingCount, showSpinner, showPendingWarning, connStatusClassName } = useSyncIndicator()
|
||||||
|
|
||||||
@@ -102,8 +103,8 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
try {
|
try {
|
||||||
const data = await fetchLogbooks()
|
const data = await fetchLogbooks()
|
||||||
setLogbooks(data)
|
setLogbooks(data)
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message || 'Failed to load logbooks')
|
setError(getErrorMessage(err, t('errors.load_failed')))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
setRefreshing(false)
|
setRefreshing(false)
|
||||||
@@ -121,8 +122,8 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
setLogbooks((prev) => [created, ...prev])
|
setLogbooks((prev) => [created, ...prev])
|
||||||
setNewTitle('')
|
setNewTitle('')
|
||||||
trackPlausibleEvent(PlausibleEvents.LOGBOOK_CREATED)
|
trackPlausibleEvent(PlausibleEvents.LOGBOOK_CREATED)
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message || 'Failed to create logbook')
|
setError(getErrorMessage(err, t('errors.save_failed')))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -138,7 +139,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
await deleteLogbook(id)
|
await deleteLogbook(id)
|
||||||
setLogbooks((prev) => prev.filter((lb) => lb.id !== id))
|
setLogbooks((prev) => prev.filter((lb) => lb.id !== id))
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Failed to delete logbook')
|
setError(getErrorMessage(err, t('errors.delete_failed')))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -182,7 +183,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Failed to update logbook title')
|
setError(getErrorMessage(err, t('errors.save_failed')))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -225,10 +226,18 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={lb.id}
|
key={lb.id}
|
||||||
className={`logbook-card glass${lb.isShared ? ' logbook-card--shared' : ''}`}
|
className={`logbook-card glass${lb.isShared ? ' logbook-card--shared' : ''}${isEditingTitle ? ' logbook-card--editing-title' : ''}`}
|
||||||
onClick={() => onSelectLogbook(lb.id, lb.title)}
|
|
||||||
>
|
>
|
||||||
<div className="card-icon">
|
{!isEditingTitle && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="logbook-card-select"
|
||||||
|
onClick={() => onSelectLogbook(lb.id, lb.title)}
|
||||||
|
aria-label={t('dashboard.open_logbook', { title: lb.title })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card-icon" aria-hidden>
|
||||||
<BookOpen size={24} />
|
<BookOpen size={24} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -241,7 +250,6 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
className="logbook-title-inline-edit input-text"
|
className="logbook-title-inline-edit input-text"
|
||||||
value={editingTitleDraft}
|
value={editingTitleDraft}
|
||||||
onChange={(e) => setEditingTitleDraft(e.target.value)}
|
onChange={(e) => setEditingTitleDraft(e.target.value)}
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -370,18 +378,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Skipper profile */}
|
<ProfileHeaderButton onClick={onOpenProfile} />
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-icon skipper-badge"
|
|
||||||
onClick={onOpenProfile}
|
|
||||||
title={t('dashboard.open_profile', { name: username })}
|
|
||||||
aria-label={t('dashboard.open_profile', { name: username })}
|
|
||||||
data-tour="nav-profile"
|
|
||||||
>
|
|
||||||
<User size={18} aria-hidden="true" />
|
|
||||||
<span className="skipper-badge__name">{username}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Lang toggle */}
|
{/* Lang toggle */}
|
||||||
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
|
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
import React, { createContext, useContext, useState, useRef, useCallback, useMemo } from 'react'
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
useRef,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useEffect,
|
||||||
|
useId
|
||||||
|
} from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
interface DialogContextType {
|
interface DialogContextType {
|
||||||
showAlert: (message: string, title?: string, confirmText?: string) => Promise<void>
|
showAlert: (message: string, title?: string, confirmText?: string) => Promise<void>
|
||||||
@@ -16,6 +26,11 @@ export function useDialog() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DialogProvider({ children }: { children: React.ReactNode }) {
|
export function DialogProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const titleId = useId()
|
||||||
|
const messageId = useId()
|
||||||
|
const confirmRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [message, setMessage] = useState('')
|
const [message, setMessage] = useState('')
|
||||||
@@ -23,19 +38,20 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const [confirmLabel, setConfirmLabel] = useState('OK')
|
const [confirmLabel, setConfirmLabel] = useState('OK')
|
||||||
const [cancelLabel, setCancelLabel] = useState('Cancel')
|
const [cancelLabel, setCancelLabel] = useState('Cancel')
|
||||||
|
|
||||||
const resolveRef = useRef<((val: any) => void) | null>(null)
|
const alertResolveRef = useRef<(() => void) | null>(null)
|
||||||
|
const confirmResolveRef = useRef<((val: boolean) => void) | null>(null)
|
||||||
|
|
||||||
const showAlert = useCallback((msg: string, headerTitle?: string, btnText?: string): Promise<void> => {
|
const showAlert = useCallback((msg: string, headerTitle?: string, btnText?: string): Promise<void> => {
|
||||||
setMessage(msg)
|
setMessage(msg)
|
||||||
setTitle(headerTitle || '')
|
setTitle(headerTitle || '')
|
||||||
setType('alert')
|
setType('alert')
|
||||||
setConfirmLabel(btnText || 'OK')
|
setConfirmLabel(btnText || t('dialog.ok'))
|
||||||
setIsOpen(true)
|
setIsOpen(true)
|
||||||
|
|
||||||
return new Promise<void>((resolve) => {
|
return new Promise<void>((resolve) => {
|
||||||
resolveRef.current = resolve
|
alertResolveRef.current = resolve
|
||||||
})
|
})
|
||||||
}, [])
|
}, [t])
|
||||||
|
|
||||||
const showConfirm = useCallback((
|
const showConfirm = useCallback((
|
||||||
msg: string,
|
msg: string,
|
||||||
@@ -46,31 +62,47 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setMessage(msg)
|
setMessage(msg)
|
||||||
setTitle(headerTitle || '')
|
setTitle(headerTitle || '')
|
||||||
setType('confirm')
|
setType('confirm')
|
||||||
setConfirmLabel(btnConfirm || 'Yes')
|
setConfirmLabel(btnConfirm || t('dialog.yes'))
|
||||||
setCancelLabel(btnCancel || 'No')
|
setCancelLabel(btnCancel || t('dialog.no'))
|
||||||
setIsOpen(true)
|
setIsOpen(true)
|
||||||
|
|
||||||
return new Promise<boolean>((resolve) => {
|
return new Promise<boolean>((resolve) => {
|
||||||
resolveRef.current = resolve
|
confirmResolveRef.current = resolve
|
||||||
})
|
})
|
||||||
}, [])
|
}, [t])
|
||||||
|
|
||||||
const handleConfirm = useCallback(() => {
|
const handleConfirm = useCallback(() => {
|
||||||
setIsOpen(false)
|
setIsOpen(false)
|
||||||
if (resolveRef.current) {
|
if (type === 'confirm' && confirmResolveRef.current) {
|
||||||
resolveRef.current(type === 'confirm' ? true : undefined)
|
confirmResolveRef.current(true)
|
||||||
resolveRef.current = null
|
confirmResolveRef.current = null
|
||||||
|
} else if (alertResolveRef.current) {
|
||||||
|
alertResolveRef.current()
|
||||||
|
alertResolveRef.current = null
|
||||||
}
|
}
|
||||||
}, [type])
|
}, [type])
|
||||||
|
|
||||||
const handleCancel = useCallback(() => {
|
const handleCancel = useCallback(() => {
|
||||||
setIsOpen(false)
|
setIsOpen(false)
|
||||||
if (resolveRef.current) {
|
if (confirmResolveRef.current) {
|
||||||
resolveRef.current(false)
|
confirmResolveRef.current(false)
|
||||||
resolveRef.current = null
|
confirmResolveRef.current = null
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return
|
||||||
|
confirmRef.current?.focus()
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (type === 'confirm') handleCancel()
|
||||||
|
else handleConfirm()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown)
|
||||||
|
}, [isOpen, type, handleCancel, handleConfirm])
|
||||||
|
|
||||||
const contextValue = useMemo(
|
const contextValue = useMemo(
|
||||||
() => ({ showAlert, showConfirm }),
|
() => ({ showAlert, showConfirm }),
|
||||||
[showAlert, showConfirm]
|
[showAlert, showConfirm]
|
||||||
@@ -80,17 +112,44 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
|
|||||||
<DialogContext.Provider value={contextValue}>
|
<DialogContext.Provider value={contextValue}>
|
||||||
{children}
|
{children}
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="custom-dialog-overlay" onClick={type === 'alert' ? handleConfirm : undefined}>
|
<div
|
||||||
<div className="custom-dialog-card glass scale-in" onClick={(e) => e.stopPropagation()}>
|
className="custom-dialog-overlay"
|
||||||
{title && <h3 className="custom-dialog-title">{title}</h3>}
|
onClick={type === 'confirm' ? handleCancel : handleConfirm}
|
||||||
<p className="custom-dialog-message">{message}</p>
|
>
|
||||||
|
<div
|
||||||
|
className="custom-dialog-card glass scale-in"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={title ? titleId : undefined}
|
||||||
|
aria-describedby={messageId}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{title && (
|
||||||
|
<h3 id={titleId} className="custom-dialog-title">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
|
<p id={messageId} className="custom-dialog-message">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
<div className="custom-dialog-actions">
|
<div className="custom-dialog-actions">
|
||||||
{type === 'confirm' && (
|
{type === 'confirm' && (
|
||||||
<button type="button" className="btn secondary" onClick={handleCancel} style={{ width: 'auto', padding: '8px 20px', margin: 0 }}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={handleCancel}
|
||||||
|
style={{ width: 'auto', padding: '8px 20px', margin: 0 }}
|
||||||
|
>
|
||||||
{cancelLabel}
|
{cancelLabel}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button type="button" className="btn primary" onClick={handleConfirm} style={{ width: 'auto', minWidth: '80px', padding: '8px 20px', margin: 0 }}>
|
<button
|
||||||
|
ref={confirmRef}
|
||||||
|
type="button"
|
||||||
|
className="btn primary"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
style={{ width: 'auto', minWidth: '80px', padding: '8px 20px', margin: 0 }}
|
||||||
|
>
|
||||||
{confirmLabel}
|
{confirmLabel}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,333 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { FileText, X } from 'lucide-react'
|
||||||
|
import type { LogEventPayload } from '../utils/logEntryPayload.js'
|
||||||
|
import { sortLogEventsByTime } from '../utils/logEntryPayload.js'
|
||||||
|
import { parseNmeaFile, nmeaPointsToWaypoints } from '../services/nmea/nmeaParse.js'
|
||||||
|
import { filterPointsForDate } from '../services/nmea/nmeaTimeSeries.js'
|
||||||
|
import { generateNmeaJournalCandidates } from '../services/nmea/nmeaJournalGenerator.js'
|
||||||
|
import type { NmeaImportMode, NmeaParseResult } from '../services/nmea/nmeaTypes.js'
|
||||||
|
import { saveNmeaArchive, recordNmeaFileImport, type NmeaArchiveRecord } from '../services/nmeaArchive.js'
|
||||||
|
import { nmeaFileCrc32 } from '../utils/crc32.js'
|
||||||
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
|
import type { TrackWaypoint } from '../services/trackUpload.js'
|
||||||
|
|
||||||
|
interface NmeaImportWizardProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
logbookId: string
|
||||||
|
entryId: string
|
||||||
|
entryDate: string
|
||||||
|
nmeaArchive: NmeaArchiveRecord | null
|
||||||
|
onImport: (events: LogEventPayload[], waypoints?: TrackWaypoint[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type WizardStep = 'config' | 'preview' | 'archive'
|
||||||
|
|
||||||
|
export default function NmeaImportWizard({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
logbookId,
|
||||||
|
entryId,
|
||||||
|
entryDate,
|
||||||
|
nmeaArchive,
|
||||||
|
onImport
|
||||||
|
}: NmeaImportWizardProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [step, setStep] = useState<WizardStep>('config')
|
||||||
|
const [parseResult, setParseResult] = useState<NmeaParseResult | null>(null)
|
||||||
|
const [mode, setMode] = useState<NmeaImportMode>('both')
|
||||||
|
const [intervalMinutes, setIntervalMinutes] = useState(60)
|
||||||
|
const [importTrack, setImportTrack] = useState(true)
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [pendingRaw, setPendingRaw] = useState<{ filename: string; text: string } | null>(null)
|
||||||
|
const [duplicateFile, setDuplicateFile] = useState(false)
|
||||||
|
|
||||||
|
const filteredPoints = useMemo(() => {
|
||||||
|
if (!parseResult) return []
|
||||||
|
return filterPointsForDate(parseResult.points, entryDate)
|
||||||
|
}, [parseResult, entryDate])
|
||||||
|
|
||||||
|
const candidates = useMemo(() => {
|
||||||
|
if (!parseResult || filteredPoints.length === 0) return []
|
||||||
|
return generateNmeaJournalCandidates({
|
||||||
|
points: filteredPoints,
|
||||||
|
mode,
|
||||||
|
intervalMinutes,
|
||||||
|
t
|
||||||
|
}).candidates
|
||||||
|
}, [parseResult, filteredPoints, mode, intervalMinutes, t])
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setStep('config')
|
||||||
|
setParseResult(null)
|
||||||
|
setMode('both')
|
||||||
|
setIntervalMinutes(60)
|
||||||
|
setImportTrack(true)
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
setError(null)
|
||||||
|
setDuplicateFile(false)
|
||||||
|
setPendingRaw(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
reset()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFile = (file: File) => {
|
||||||
|
setError(null)
|
||||||
|
setDuplicateFile(false)
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
try {
|
||||||
|
const text = String(reader.result ?? '')
|
||||||
|
const crc32 = nmeaFileCrc32(text)
|
||||||
|
const alreadyImported = nmeaArchive?.importedFiles.some((item) => item.crc32 === crc32) ?? false
|
||||||
|
setDuplicateFile(alreadyImported)
|
||||||
|
const result = parseNmeaFile(text, file.name)
|
||||||
|
if (result.points.length === 0) {
|
||||||
|
setError(t('logs.nmea_error_no_samples'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setParseResult(result)
|
||||||
|
setPendingRaw({ filename: file.name, text })
|
||||||
|
const generated = generateNmeaJournalCandidates({
|
||||||
|
points: filterPointsForDate(result.points, entryDate),
|
||||||
|
mode,
|
||||||
|
intervalMinutes,
|
||||||
|
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'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.onerror = () => setError(t('logs.nmea_error_read'))
|
||||||
|
reader.readAsText(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleAll = (checked: boolean) => {
|
||||||
|
setSelectedIds(checked ? new Set(candidates.map((c) => c.id)) : new Set())
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleOne = (id: string) => {
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(id)) next.delete(id)
|
||||||
|
else next.add(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const goPreview = () => {
|
||||||
|
if (!parseResult) {
|
||||||
|
setError(t('logs.nmea_error_no_file'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const generated = generateNmeaJournalCandidates({
|
||||||
|
points: filteredPoints,
|
||||||
|
mode,
|
||||||
|
intervalMinutes,
|
||||||
|
t
|
||||||
|
}).candidates
|
||||||
|
setSelectedIds(new Set(generated.map((c) => c.id)))
|
||||||
|
setStep('preview')
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyImport = async () => {
|
||||||
|
const picked = candidates.filter((c) => selectedIds.has(c.id)).map((c) => c.event)
|
||||||
|
if (picked.length === 0) {
|
||||||
|
setError(t('logs.nmea_error_no_selection'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const waypoints = importTrack ? nmeaPointsToWaypoints(filteredPoints) : undefined
|
||||||
|
onImport(sortLogEventsByTime(picked), waypoints)
|
||||||
|
if (pendingRaw) {
|
||||||
|
try {
|
||||||
|
await recordNmeaFileImport(logbookId, entryId, pendingRaw.filename, pendingRaw.text)
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('NMEA import CRC record failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
trackPlausibleEvent(PlausibleEvents.NMEA_IMPORTED, {
|
||||||
|
mode,
|
||||||
|
events: picked.length,
|
||||||
|
track: importTrack && (waypoints?.length ?? 0) > 0
|
||||||
|
})
|
||||||
|
setStep('archive')
|
||||||
|
}
|
||||||
|
|
||||||
|
const finishArchive = async (archive: boolean) => {
|
||||||
|
try {
|
||||||
|
if (archive && pendingRaw) {
|
||||||
|
await saveNmeaArchive(logbookId, entryId, pendingRaw.filename, pendingRaw.text)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('NMEA archive save failed:', err)
|
||||||
|
}
|
||||||
|
handleClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') handleClose()
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKeyDown)
|
||||||
|
const prevOverflow = document.body.style.overflow
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
|
document.body.style.overflow = prevOverflow
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="disclaimer-modal-overlay" onClick={handleClose}>
|
||||||
|
<div className="disclaimer-modal-panel" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="auth-card glass registration-disclaimer registration-disclaimer--modal feedback-modal">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="registration-disclaimer__close feedback-modal__close"
|
||||||
|
onClick={handleClose}
|
||||||
|
aria-label={t('logs.nmea_cancel')}
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="auth-header">
|
||||||
|
<FileText className="auth-icon accent" size={40} />
|
||||||
|
<h2>{t('logs.nmea_import_title')}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="track-error-msg">{error}</div>}
|
||||||
|
|
||||||
|
{duplicateFile && (
|
||||||
|
<div className="nmea-import-warning" role="status">
|
||||||
|
{t('logs.nmea_warn_duplicate_file')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'config' && (
|
||||||
|
<>
|
||||||
|
<p className="registration-disclaimer__intro">{t('logs.nmea_import_intro')}</p>
|
||||||
|
<label className="feedback-form__field">
|
||||||
|
<span>{t('logs.nmea_file_label')}</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".nmea,.log,.txt"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (file) handleFile(file)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{parseResult && (
|
||||||
|
<div className="nmea-import-summary">
|
||||||
|
<p>{t('logs.nmea_stats', {
|
||||||
|
lines: parseResult.stats.parsedLines,
|
||||||
|
types: parseResult.stats.sentenceTypes.join(', ')
|
||||||
|
})}</p>
|
||||||
|
{parseResult.warnings.includes('no_position') && (
|
||||||
|
<p>{t('logs.nmea_warn_no_position')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<fieldset className="nmea-import-mode">
|
||||||
|
<legend>{t('logs.nmea_mode_label')}</legend>
|
||||||
|
<label><input type="radio" name="nmea-mode" checked={mode === 'interval'} onChange={() => setMode('interval')} /> {t('logs.nmea_mode_interval')}</label>
|
||||||
|
<label><input type="radio" name="nmea-mode" checked={mode === 'change'} onChange={() => setMode('change')} /> {t('logs.nmea_mode_change')}</label>
|
||||||
|
<label><input type="radio" name="nmea-mode" checked={mode === 'both'} onChange={() => setMode('both')} /> {t('logs.nmea_mode_both')}</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{(mode === 'interval' || mode === 'both') && (
|
||||||
|
<label className="feedback-form__field">
|
||||||
|
<span>{t('logs.nmea_interval_label')}</span>
|
||||||
|
<select value={intervalMinutes} onChange={(e) => setIntervalMinutes(Number(e.target.value))}>
|
||||||
|
<option value={30}>30 min</option>
|
||||||
|
<option value={60}>60 min</option>
|
||||||
|
<option value={90}>90 min</option>
|
||||||
|
<option value={120}>120 min</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label className="nmea-import-checkbox">
|
||||||
|
<input type="checkbox" checked={importTrack} onChange={(e) => setImportTrack(e.target.checked)} />
|
||||||
|
{t('logs.nmea_import_track')}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="auth-actions feedback-form__actions">
|
||||||
|
<button type="button" className="btn secondary" onClick={handleClose}>{t('logs.nmea_cancel')}</button>
|
||||||
|
<button type="button" className="btn primary" onClick={goPreview} disabled={!parseResult}>
|
||||||
|
{t('logs.nmea_preview')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'preview' && (
|
||||||
|
<>
|
||||||
|
<p>{t('logs.nmea_preview_hint', { count: candidates.length })}</p>
|
||||||
|
<div className="nmea-preview-actions">
|
||||||
|
<button type="button" className="btn secondary" onClick={() => toggleAll(true)}>{t('logs.nmea_select_all')}</button>
|
||||||
|
<button type="button" className="btn secondary" onClick={() => toggleAll(false)}>{t('logs.nmea_select_none')}</button>
|
||||||
|
</div>
|
||||||
|
<div className="nmea-preview-list">
|
||||||
|
{candidates.map((c) => (
|
||||||
|
<label key={c.id} className="nmea-preview-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="nmea-preview-row__check"
|
||||||
|
checked={selectedIds.has(c.id)}
|
||||||
|
onChange={() => toggleOne(c.id)}
|
||||||
|
/>
|
||||||
|
<div className="nmea-preview-row__body">
|
||||||
|
<div className="nmea-preview-row__meta">
|
||||||
|
<span className="nmea-preview-time">{c.event.time}</span>
|
||||||
|
<span className="nmea-preview-source">{t(`logs.nmea_source_${c.source}`)}</span>
|
||||||
|
</div>
|
||||||
|
<span className="nmea-preview-remarks">{c.event.remarks || c.event.mgk || '—'}</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="auth-actions feedback-form__actions">
|
||||||
|
<button type="button" className="btn secondary" onClick={() => setStep('config')}>{t('logs.nmea_back')}</button>
|
||||||
|
<button type="button" className="btn primary" onClick={applyImport}>{t('logs.nmea_apply')}</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'archive' && (
|
||||||
|
<>
|
||||||
|
<p>{t('logs.nmea_archive_question')}</p>
|
||||||
|
<div className="auth-actions feedback-form__actions">
|
||||||
|
<button type="button" className="btn secondary" onClick={() => finishArchive(false)}>
|
||||||
|
{t('logs.nmea_archive_discard')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn primary" onClick={() => finishArchive(true)}>
|
||||||
|
{t('logs.nmea_archive_keep')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,9 +3,9 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { db } from '../services/db.js'
|
import { db } from '../services/db.js'
|
||||||
import { getActiveMasterKey } from '../services/auth.js'
|
import { getActiveMasterKey } from '../services/auth.js'
|
||||||
import { getLogbookKey } from '../services/logbookKeys.js'
|
import { getLogbookKey } from '../services/logbookKeys.js'
|
||||||
import { encryptJson, decryptJson } from '../services/crypto.js'
|
import { decryptJson } from '../services/crypto.js'
|
||||||
import { syncLogbook } from '../services/sync.js'
|
import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.js'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { fileToCompressedJpegDataUrl } from '../utils/imageCompress.js'
|
||||||
import { useLiveQuery } from 'dexie-react-hooks'
|
import { useLiveQuery } from 'dexie-react-hooks'
|
||||||
import { useDialog } from './ModalDialog.tsx'
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
import { Camera, Trash2 } from 'lucide-react'
|
import { Camera, Trash2 } from 'lucide-react'
|
||||||
@@ -90,109 +90,30 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
|
|||||||
setUploading(true)
|
setUploading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
const reader = new FileReader()
|
|
||||||
reader.onload = (event) => {
|
|
||||||
const img = new Image()
|
|
||||||
img.onload = async () => {
|
|
||||||
try {
|
try {
|
||||||
const canvas = document.createElement('canvas')
|
const compressedBase64 = await fileToCompressedJpegDataUrl(file)
|
||||||
const ctx = canvas.getContext('2d')
|
await saveEntryPhoto({
|
||||||
if (!ctx) throw new Error('Could not get canvas context')
|
logbookId,
|
||||||
|
|
||||||
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,
|
entryId,
|
||||||
logbookId,
|
imageDataUrl: compressedBase64,
|
||||||
encryptedData: encrypted.ciphertext,
|
caption: caption.trim(),
|
||||||
iv: encrypted.iv,
|
analyticsContext: 'logbook'
|
||||||
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('')
|
setCaption('')
|
||||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||||
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: 'logbook' })
|
} catch (err: unknown) {
|
||||||
|
|
||||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Failed to process image:', err)
|
console.error('Failed to process image:', err)
|
||||||
setError(err.message || 'Failed to process image')
|
setError(err instanceof Error ? err.message : 'Failed to process image')
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false)
|
setUploading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
img.src = event.target?.result as string
|
|
||||||
}
|
|
||||||
reader.readAsDataURL(file)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDelete = async (photoId: string) => {
|
const handleDelete = async (photoId: string) => {
|
||||||
if (await showConfirm(t('logs.photo_delete_confirm'), t('logs.photos_title'), t('logs.confirm_yes'), t('logs.confirm_no'))) {
|
if (await showConfirm(t('logs.photo_delete_confirm'), t('logs.photos_title'), t('logs.confirm_yes'), t('logs.confirm_no'))) {
|
||||||
try {
|
try {
|
||||||
const now = new Date().toISOString()
|
await deleteEntryPhoto(logbookId, photoId)
|
||||||
|
} catch (err: unknown) {
|
||||||
await db.photos.delete(photoId)
|
|
||||||
|
|
||||||
await db.syncQueue.put({
|
|
||||||
action: 'delete',
|
|
||||||
type: 'photo',
|
|
||||||
payloadId: photoId,
|
|
||||||
logbookId,
|
|
||||||
data: '',
|
|
||||||
updatedAt: now
|
|
||||||
})
|
|
||||||
|
|
||||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Failed to delete photo:', err)
|
console.error('Failed to delete photo:', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { User } from 'lucide-react'
|
||||||
|
|
||||||
|
interface ProfileHeaderButtonProps {
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProfileHeaderButton({ onClick }: ProfileHeaderButtonProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const username = localStorage.getItem('active_username') || 'Skipper'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-icon skipper-badge"
|
||||||
|
onClick={onClick}
|
||||||
|
title={t('dashboard.open_profile', { name: username })}
|
||||||
|
aria-label={t('dashboard.open_profile', { name: username })}
|
||||||
|
data-tour="nav-profile"
|
||||||
|
>
|
||||||
|
<User size={18} aria-hidden="true" />
|
||||||
|
<span className="skipper-badge__name">{username}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { Settings as SettingsIcon, Check, Users, Trash2, Copy, Link as LinkIcon } from 'lucide-react'
|
import { Settings as SettingsIcon, Check, Users, Trash2, Copy, Link as LinkIcon } from 'lucide-react'
|
||||||
import { ensureLogbookKey } from '../services/logbookKeys.js'
|
import { ensureLogbookKey } from '../services/logbookKeys.js'
|
||||||
import LogbookBackupPanel from './LogbookBackupPanel.tsx'
|
import LogbookBackupPanel from './LogbookBackupPanel.tsx'
|
||||||
|
import LinkQrCode from './LinkQrCode.tsx'
|
||||||
import { useDialog } from './ModalDialog.tsx'
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
import { apiFetch } from '../services/api.js'
|
import { apiFetch } from '../services/api.js'
|
||||||
@@ -314,7 +315,8 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{shareEnabled && shareLink && (
|
{shareEnabled && shareLink && (
|
||||||
<div className="input-group mb-4 copy-link-row">
|
<div className="link-with-qr mb-4">
|
||||||
|
<div className="input-group copy-link-row">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
readOnly
|
readOnly
|
||||||
@@ -328,10 +330,13 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
|||||||
className="btn secondary"
|
className="btn secondary"
|
||||||
onClick={handleCopyShareLink}
|
onClick={handleCopyShareLink}
|
||||||
style={{ width: 'auto', padding: '10px' }}
|
style={{ width: 'auto', padding: '10px' }}
|
||||||
|
title={t('settings.share_copy_btn')}
|
||||||
>
|
>
|
||||||
{shareCopied ? <Check size={16} /> : <Copy size={16} />}
|
{shareCopied ? <Check size={16} /> : <Copy size={16} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<LinkQrCode value={shareLink} />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -367,7 +372,8 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{inviteLink && (
|
{inviteLink && (
|
||||||
<div className="input-group mb-6 copy-link-row">
|
<div className="link-with-qr mb-6">
|
||||||
|
<div className="input-group copy-link-row">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
readOnly
|
readOnly
|
||||||
@@ -381,10 +387,13 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
|||||||
className="btn secondary"
|
className="btn secondary"
|
||||||
onClick={handleCopyInvite}
|
onClick={handleCopyInvite}
|
||||||
style={{ width: 'auto', padding: '10px' }}
|
style={{ width: 'auto', padding: '10px' }}
|
||||||
|
title={t('settings.share_copy_btn')}
|
||||||
>
|
>
|
||||||
{inviteCopied ? <Check size={16} /> : <Copy size={16} />}
|
{inviteCopied ? <Check size={16} /> : <Copy size={16} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<LinkQrCode value={inviteLink} />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<h4 style={{ color: '#fbbf24', fontSize: '14px', marginBottom: '12px' }}>
|
<h4 style={{ color: '#fbbf24', fontSize: '14px', marginBottom: '12px' }}>
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ import {
|
|||||||
} from '../services/statsAggregation.js'
|
} from '../services/statsAggregation.js'
|
||||||
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
|
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
|
||||||
import { formatFuelPerMotorHour } from '../utils/fuelStats.js'
|
import { formatFuelPerMotorHour } from '../utils/fuelStats.js'
|
||||||
|
import {
|
||||||
|
loadLogbookEventSeries,
|
||||||
|
type EventSeriesPoint,
|
||||||
|
type EventSeriesSummary
|
||||||
|
} from '../services/eventSeriesAggregation.js'
|
||||||
|
|
||||||
interface StatsDashboardProps {
|
interface StatsDashboardProps {
|
||||||
logbookId: string
|
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 { t } = useTranslation()
|
||||||
const { travelDays, routePorts, trackSegments, totals } = summary
|
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>
|
<h3 className="stats-section-title">{t('stats.propulsion_title')}</h3>
|
||||||
<PropulsionBreakdown totals={totals} />
|
<PropulsionBreakdown totals={totals} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{eventSeries && <EventSeriesPanel series={eventSeries} />}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -323,18 +385,21 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [logbookStats, setLogbookStats] = useState<LogbookStatsSummary | 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 [accountStats, setAccountStats] = useState<Awaited<ReturnType<typeof loadAccountStats>> | null>(null)
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const [lb, acc] = await Promise.all([
|
const [lb, acc, series] = await Promise.all([
|
||||||
loadLogbookStats(logbookId, logbookTitle, true),
|
loadLogbookStats(logbookId, logbookTitle, true),
|
||||||
loadAccountStats(false)
|
loadAccountStats(false),
|
||||||
|
loadLogbookEventSeries(logbookId)
|
||||||
])
|
])
|
||||||
setLogbookStats(lb)
|
setLogbookStats(lb)
|
||||||
setAccountStats(acc)
|
setAccountStats(acc)
|
||||||
|
setEventSeries(series)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('Failed to load statistics:', err)
|
console.error('Failed to load statistics:', err)
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load statistics.')
|
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>
|
<p>{t('stats.loading')}</p>
|
||||||
</div>
|
</div>
|
||||||
) : scope === 'logbook' && logbookStats ? (
|
) : scope === 'logbook' && logbookStats ? (
|
||||||
<LogbookScopeView summary={logbookStats} />
|
<LogbookScopeView summary={logbookStats} eventSeries={eventSeries} />
|
||||||
) : scope === 'account' && accountStats ? (
|
) : scope === 'account' && accountStats ? (
|
||||||
<>
|
<>
|
||||||
<TotalsGrid totals={accountStats.totals} />
|
<TotalsGrid totals={accountStats.totals} />
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { AlertTriangle } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
getSyncConflicts,
|
||||||
|
subscribeSyncConflicts,
|
||||||
|
type SyncConflict
|
||||||
|
} from '../services/syncConflicts.js'
|
||||||
|
import {
|
||||||
|
resolveSyncConflictKeepLocal,
|
||||||
|
resolveSyncConflictUseServer
|
||||||
|
} from '../services/sync.js'
|
||||||
|
|
||||||
|
interface SyncConflictBannerProps {
|
||||||
|
logbookId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SyncConflictBanner({ logbookId }: SyncConflictBannerProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [items, setItems] = useState<SyncConflict[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const refresh = () => {
|
||||||
|
setItems(logbookId ? getSyncConflicts(logbookId) : getSyncConflicts())
|
||||||
|
}
|
||||||
|
refresh()
|
||||||
|
return subscribeSyncConflicts(refresh)
|
||||||
|
}, [logbookId])
|
||||||
|
|
||||||
|
if (items.length === 0) return null
|
||||||
|
|
||||||
|
const first = items[0]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sync-conflict-banner" role="alert">
|
||||||
|
<AlertTriangle size={20} aria-hidden />
|
||||||
|
<div className="sync-conflict-banner__body">
|
||||||
|
<strong>{t('sync.conflict_title')}</strong>
|
||||||
|
<p>
|
||||||
|
{t('sync.conflict_message', {
|
||||||
|
count: items.length,
|
||||||
|
id: first.payloadId.slice(0, 8)
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<div className="sync-conflict-banner__actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={() => void resolveSyncConflictUseServer(first)}
|
||||||
|
>
|
||||||
|
{t('sync.conflict_use_server')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn primary"
|
||||||
|
onClick={() => void resolveSyncConflictKeepLocal(first)}
|
||||||
|
>
|
||||||
|
{t('sync.conflict_keep_local')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,6 +13,17 @@
|
|||||||
"sv": "Svenska",
|
"sv": "Svenska",
|
||||||
"nb": "Norsk"
|
"nb": "Norsk"
|
||||||
},
|
},
|
||||||
|
"dialog": {
|
||||||
|
"ok": "OK",
|
||||||
|
"yes": "Ja",
|
||||||
|
"no": "Nej"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"load_failed": "Data kunne ikke indlæses.",
|
||||||
|
"save_failed": "Ændringer kunne ikke gemmes.",
|
||||||
|
"delete_failed": "Sletning mislykkedes.",
|
||||||
|
"export_failed": "Eksport mislykkedes."
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"unsaved_changes_title": "Ikke gemte ændringer",
|
"unsaved_changes_title": "Ikke gemte ændringer",
|
||||||
"unsaved_changes_message": "Du har ændringer, der ikke er gemt. Vil du virkelig forlade siden? Dine ændringer vil gå tabt.",
|
"unsaved_changes_message": "Du har ændringer, der ikke er gemt. Vil du virkelig forlade siden? Dine ændringer vil gå tabt.",
|
||||||
@@ -68,7 +79,12 @@
|
|||||||
"enter_pin_placeholder": "Indtast din pinkode...",
|
"enter_pin_placeholder": "Indtast din pinkode...",
|
||||||
"decrypt_with_pin": "Afkodning",
|
"decrypt_with_pin": "Afkodning",
|
||||||
"use_recovery_instead": "Brug genoprettelsesnøgler i stedet",
|
"use_recovery_instead": "Brug genoprettelsesnøgler i stedet",
|
||||||
"error_incorrect_pin": "Forkert PIN-kode. Dekryptering mislykkedes."
|
"error_incorrect_pin": "Forkert PIN-kode. Dekryptering mislykkedes.",
|
||||||
|
"error_invalid_host": "Passkeys virker ikke via 127.0.0.1. Åbn appen via localhost.",
|
||||||
|
"use_localhost_link": "Skift til localhost",
|
||||||
|
"error_passkey_cancelled": "Passkey-login blev annulleret eller udløb. Prøv igen.",
|
||||||
|
"error_invalid_rp_id": "Passkey-domæne matcher ikke (RP ID). Brug http://localhost:5173 med RP_ID=localhost i .env til lokal udvikling.",
|
||||||
|
"error_session_incomplete": "Login ufuldstændig. Log ind med passkey igen."
|
||||||
},
|
},
|
||||||
"pwa": {
|
"pwa": {
|
||||||
"title": "Installer app",
|
"title": "Installer app",
|
||||||
@@ -87,13 +103,18 @@
|
|||||||
"update_title": "Opdatering tilgængelig",
|
"update_title": "Opdatering tilgængelig",
|
||||||
"update_desc": "En ny version af Kapteins Daagbok er klar. Opdater venligst for at få de seneste ændringer.",
|
"update_desc": "En ny version af Kapteins Daagbok er klar. Opdater venligst for at få de seneste ændringer.",
|
||||||
"update_now": "Opdater nu",
|
"update_now": "Opdater nu",
|
||||||
"update_reloading": "Indlæser..."
|
"update_reloading": "Indlæser...",
|
||||||
|
"storage_persist_hint": "Browseren kan slette offline-data. Tillad permanent lagring, så din logbog forbliver beskyttet."
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
"status_synced": "Synkroniseret",
|
"status_synced": "Synkroniseret",
|
||||||
"status_syncing": "Synkroniser...",
|
"status_syncing": "Synkroniser...",
|
||||||
"status_offline": "Offline-cache",
|
"status_offline": "Offline-cache",
|
||||||
"status_unsynced": "Usynkroniserede ændringer"
|
"status_unsynced": "Usynkroniserede ændringer",
|
||||||
|
"conflict_title": "Synkroniseringskonflikt",
|
||||||
|
"conflict_message": "{{count}} ændring(er) kunne ikke synkroniseres (post {{id}}…). Vælg hvilken version der skal gælde.",
|
||||||
|
"conflict_use_server": "Brug serverversion",
|
||||||
|
"conflict_keep_local": "Behold min version"
|
||||||
},
|
},
|
||||||
"vessel": {
|
"vessel": {
|
||||||
"title": "Skibets stamdata",
|
"title": "Skibets stamdata",
|
||||||
@@ -145,7 +166,8 @@
|
|||||||
"sign_cleared_skipper_re_sign_title": "Skippers underskrift fjernet",
|
"sign_cleared_skipper_re_sign_title": "Skippers underskrift fjernet",
|
||||||
"sign_cleared_skipper_re_sign": "Hændelsesloggen er blevet ændret. Skipperens underskrift er blevet fjernet. Godkend venligst igen.",
|
"sign_cleared_skipper_re_sign": "Hændelsesloggen er blevet ændret. Skipperens underskrift er blevet fjernet. Godkend venligst igen.",
|
||||||
"date": "dato",
|
"date": "dato",
|
||||||
"day_of_travel": "Rejsedag / rejsedag",
|
"day_of_travel": "Rejsedag",
|
||||||
|
"travel_day_number": "Rejsedag {{number}}",
|
||||||
"departure": "Starthavn (rejse fra)",
|
"departure": "Starthavn (rejse fra)",
|
||||||
"destination": "Destinationsport (til)",
|
"destination": "Destinationsport (til)",
|
||||||
"route": "Rejse fra/til",
|
"route": "Rejse fra/til",
|
||||||
@@ -197,6 +219,94 @@
|
|||||||
"saving": "Vil blive reddet...",
|
"saving": "Vil blive reddet...",
|
||||||
"saved": "Logbogsside gemt med succes!",
|
"saved": "Logbogsside gemt med succes!",
|
||||||
"loading": "Dagbogen er ved at blive indlæst.",
|
"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_entry": "Slet tag",
|
||||||
"delete_confirm": "Er du sikker på, at du vil slette denne rejsedag permanent?",
|
"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?",
|
"carry_over_tanks_title": "Overføre data fra den foregående dag?",
|
||||||
@@ -283,7 +393,57 @@
|
|||||||
"revoke": "Fjerne",
|
"revoke": "Fjerne",
|
||||||
"revoke_confirm": "Er du sikker på, at du vil tilbagekalde dette besætningsmedlems adgang?",
|
"revoke_confirm": "Er du sikker på, at du vil tilbagekalde dette besætningsmedlems adgang?",
|
||||||
"invite_role": "Rolle",
|
"invite_role": "Rolle",
|
||||||
"invite_expires": "Linket er gyldigt i 48 timer"
|
"invite_expires": "Linket er gyldigt i 48 timer",
|
||||||
|
"nmea_import_title": "Import NMEA log",
|
||||||
|
"nmea_import_intro": "Upload a .nmea file from your onboard logger. The app suggests journal entries — you choose what to import.",
|
||||||
|
"nmea_import_btn": "Import NMEA",
|
||||||
|
"nmea_file_label": "NMEA file",
|
||||||
|
"nmea_stats": "{{lines}} sentences parsed · types: {{types}}",
|
||||||
|
"nmea_warn_no_position": "No position sentences found — track and GPS fields may stay empty.",
|
||||||
|
"nmea_mode_label": "Generate journal entries",
|
||||||
|
"nmea_mode_interval": "By time interval",
|
||||||
|
"nmea_mode_change": "On significant change",
|
||||||
|
"nmea_mode_both": "Both (merge)",
|
||||||
|
"nmea_interval_label": "Interval (minutes)",
|
||||||
|
"nmea_import_track": "Import GPS track from NMEA",
|
||||||
|
"nmea_preview": "Preview",
|
||||||
|
"nmea_preview_hint": "{{count}} suggested journal entries",
|
||||||
|
"nmea_select_all": "Select all",
|
||||||
|
"nmea_select_none": "Select none",
|
||||||
|
"nmea_source_interval": "Interval",
|
||||||
|
"nmea_source_change": "Event",
|
||||||
|
"nmea_apply": "Apply to journal",
|
||||||
|
"nmea_back": "Back",
|
||||||
|
"nmea_cancel": "Cancel",
|
||||||
|
"nmea_archive_question": "Archive raw log locally? (This device only, not synced.)",
|
||||||
|
"nmea_archive_keep": "Archive",
|
||||||
|
"nmea_archive_discard": "Discard",
|
||||||
|
"nmea_archive_stored": "NMEA archived: {{name}}",
|
||||||
|
"nmea_archive_delete_confirm": "Delete archived NMEA log from this device?",
|
||||||
|
"nmea_error_no_samples": "No usable NMEA sentences in the file.",
|
||||||
|
"nmea_error_parse": "Could not read NMEA file.",
|
||||||
|
"nmea_error_read": "Could not read file.",
|
||||||
|
"nmea_error_no_file": "Please choose an NMEA file first.",
|
||||||
|
"nmea_error_no_selection": "Please select at least one journal entry.",
|
||||||
|
"nmea_remark_interval": "NMEA interval",
|
||||||
|
"nmea_remark_uncertain": "uncertain",
|
||||||
|
"nmea_remark_depth": "Depth {{depth}} m",
|
||||||
|
"nmea_change_course": "Course change {{from}}° → {{to}}°",
|
||||||
|
"nmea_change_wind": "Wind {{from}}° → {{to}}°",
|
||||||
|
"nmea_change_wind_speed": "Wind {{from}} → {{to}} kn",
|
||||||
|
"nmea_change_pressure": "Pressure {{from}} → {{to}} hPa",
|
||||||
|
"nmea_change_depth": "Depth {{from}} → {{to}} m",
|
||||||
|
"nmea_change_engine_start": "Engine on ({{rpm}} rpm)",
|
||||||
|
"nmea_change_engine_stop": "Engine off",
|
||||||
|
"nmea_change_autopilot_on": "Autopilot on",
|
||||||
|
"nmea_change_autopilot_off": "Autopilot off",
|
||||||
|
"nmea_change_gps_lost": "GPS fix lost",
|
||||||
|
"nmea_change_gps_regained": "GPS fix restored",
|
||||||
|
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
|
||||||
|
"nmea_change_departure": "Departure / underway",
|
||||||
|
"nmea_change_anchor": "Anchored / stop",
|
||||||
|
"nmea_change_speed": "Speed {{from}} → {{to}} kn",
|
||||||
|
"nmea_warn_duplicate_file": "This NMEA file has already been imported. Importing the same file again will add duplicate journal entries."
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dine logbøger",
|
"title": "Dine logbøger",
|
||||||
@@ -308,6 +468,7 @@
|
|||||||
"role_read": "Læs kun",
|
"role_read": "Læs kun",
|
||||||
"role_read_hint": "Opdelt logbog - kun visning, ingen redigering",
|
"role_read_hint": "Opdelt logbog - kun visning, ingen redigering",
|
||||||
"open_profile": "Åben profil af {{name}}",
|
"open_profile": "Åben profil af {{name}}",
|
||||||
|
"open_logbook": "Åbn logbog „{{title}}“",
|
||||||
"edit_title": "Omdøb logbog",
|
"edit_title": "Omdøb logbog",
|
||||||
"edit_placeholder": "Nyt navn på logbogen",
|
"edit_placeholder": "Nyt navn på logbogen",
|
||||||
"edit_success": "Logbog omdøbt med succes",
|
"edit_success": "Logbog omdøbt med succes",
|
||||||
@@ -495,6 +656,8 @@
|
|||||||
"share_enable": "Aktivér offentligt link",
|
"share_enable": "Aktivér offentligt link",
|
||||||
"share_copied": "Link kopieret!",
|
"share_copied": "Link kopieret!",
|
||||||
"share_copy_btn": "Kopier link",
|
"share_copy_btn": "Kopier link",
|
||||||
|
"link_qr_hint": "Scan QR-koden med din telefon",
|
||||||
|
"link_qr_alt": "QR-kode til linket",
|
||||||
"danger_zone_title": "Farezone",
|
"danger_zone_title": "Farezone",
|
||||||
"danger_zone_desc": "Når du sletter din konto, slettes alle dine Passkey'er, logbøger, skibsdata, besætningsprofiler, rejseindlæg og E2E-nøgler uigenkaldeligt. Denne handling kan ikke fortrydes.",
|
"danger_zone_desc": "Når du sletter din konto, slettes alle dine Passkey'er, logbøger, skibsdata, besætningsprofiler, rejseindlæg og E2E-nøgler uigenkaldeligt. Denne handling kan ikke fortrydes.",
|
||||||
"delete_account_btn": "Slet konto uigenkaldeligt",
|
"delete_account_btn": "Slet konto uigenkaldeligt",
|
||||||
@@ -663,7 +826,13 @@
|
|||||||
"unit_l": "L",
|
"unit_l": "L",
|
||||||
"day_label": "Dag {{day}}",
|
"day_label": "Dag {{day}}",
|
||||||
"account_logbooks": "Et overblik over logbøger",
|
"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": {
|
"tour": {
|
||||||
"skip": "Spring turen over",
|
"skip": "Spring turen over",
|
||||||
|
|||||||
@@ -13,6 +13,17 @@
|
|||||||
"sv": "Svenska",
|
"sv": "Svenska",
|
||||||
"nb": "Norsk"
|
"nb": "Norsk"
|
||||||
},
|
},
|
||||||
|
"dialog": {
|
||||||
|
"ok": "OK",
|
||||||
|
"yes": "Ja",
|
||||||
|
"no": "Nein"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"load_failed": "Daten konnten nicht geladen werden.",
|
||||||
|
"save_failed": "Änderungen konnten nicht gespeichert werden.",
|
||||||
|
"delete_failed": "Löschen fehlgeschlagen.",
|
||||||
|
"export_failed": "Export fehlgeschlagen."
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"unsaved_changes_title": "Ungespeicherte Änderungen",
|
"unsaved_changes_title": "Ungespeicherte Änderungen",
|
||||||
"unsaved_changes_message": "Du hast ungespeicherte Änderungen. Möchtest du die Seite wirklich verlassen? Deine Änderungen gehen verloren.",
|
"unsaved_changes_message": "Du hast ungespeicherte Änderungen. Möchtest du die Seite wirklich verlassen? Deine Änderungen gehen verloren.",
|
||||||
@@ -68,7 +79,12 @@
|
|||||||
"enter_pin_placeholder": "Gib deine PIN ein...",
|
"enter_pin_placeholder": "Gib deine PIN ein...",
|
||||||
"decrypt_with_pin": "Entschlüsseln",
|
"decrypt_with_pin": "Entschlüsseln",
|
||||||
"use_recovery_instead": "Stattdessen Wiederherstellungsschlüssel verwenden",
|
"use_recovery_instead": "Stattdessen Wiederherstellungsschlüssel verwenden",
|
||||||
"error_incorrect_pin": "Falsche PIN. Entschlüsselung fehlgeschlagen."
|
"error_incorrect_pin": "Falsche PIN. Entschlüsselung fehlgeschlagen.",
|
||||||
|
"error_invalid_host": "Passkeys funktionieren nicht über 127.0.0.1. Bitte die App über localhost öffnen.",
|
||||||
|
"use_localhost_link": "Zu localhost wechseln",
|
||||||
|
"error_passkey_cancelled": "Passkey-Anmeldung abgebrochen oder abgelaufen. Bitte erneut versuchen.",
|
||||||
|
"error_invalid_rp_id": "Passkey-Domain passt nicht (RP ID). Lokal nur http://localhost:5173 mit RP_ID=localhost in .env verwenden.",
|
||||||
|
"error_session_incomplete": "Anmeldung unvollständig. Bitte erneut mit Passkey anmelden."
|
||||||
},
|
},
|
||||||
"pwa": {
|
"pwa": {
|
||||||
"title": "App installieren",
|
"title": "App installieren",
|
||||||
@@ -87,13 +103,18 @@
|
|||||||
"update_title": "Update verfügbar",
|
"update_title": "Update verfügbar",
|
||||||
"update_desc": "Eine neue Version von Kapteins Daagbok ist bereit. Bitte aktualisieren, um die neuesten Änderungen zu erhalten.",
|
"update_desc": "Eine neue Version von Kapteins Daagbok ist bereit. Bitte aktualisieren, um die neuesten Änderungen zu erhalten.",
|
||||||
"update_now": "Jetzt aktualisieren",
|
"update_now": "Jetzt aktualisieren",
|
||||||
"update_reloading": "Wird geladen…"
|
"update_reloading": "Wird geladen…",
|
||||||
|
"storage_persist_hint": "Der Browser kann Offline-Daten löschen. Erlaube dauerhafte Speicherung, damit dein Logbuch geschützt bleibt (in den Browser-Einstellungen oder beim nächsten Hinweis)."
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
"status_synced": "Synchronisiert",
|
"status_synced": "Synchronisiert",
|
||||||
"status_syncing": "Synchronisiere…",
|
"status_syncing": "Synchronisiere…",
|
||||||
"status_offline": "Offline-Cache",
|
"status_offline": "Offline-Cache",
|
||||||
"status_unsynced": "Unsynchronisierte Änderungen"
|
"status_unsynced": "Unsynchronisierte Änderungen",
|
||||||
|
"conflict_title": "Synchronisationskonflikt",
|
||||||
|
"conflict_message": "{{count}} Änderung(en) konnten nicht synchronisiert werden (Eintrag {{id}}…). Bitte wähle, welche Version gelten soll.",
|
||||||
|
"conflict_use_server": "Server-Version übernehmen",
|
||||||
|
"conflict_keep_local": "Meine Version behalten"
|
||||||
},
|
},
|
||||||
"vessel": {
|
"vessel": {
|
||||||
"title": "Schiffs-Stammdaten",
|
"title": "Schiffs-Stammdaten",
|
||||||
@@ -145,7 +166,8 @@
|
|||||||
"sign_cleared_skipper_re_sign_title": "Skipper-Unterschrift entfernt",
|
"sign_cleared_skipper_re_sign_title": "Skipper-Unterschrift entfernt",
|
||||||
"sign_cleared_skipper_re_sign": "Das Ereignisprotokoll wurde geändert. Die Skipper-Unterschrift wurde entfernt. Bitte erneut freigeben.",
|
"sign_cleared_skipper_re_sign": "Das Ereignisprotokoll wurde geändert. Die Skipper-Unterschrift wurde entfernt. Bitte erneut freigeben.",
|
||||||
"date": "Datum",
|
"date": "Datum",
|
||||||
"day_of_travel": "Tag der Reise / Reisetag",
|
"day_of_travel": "Reisetag",
|
||||||
|
"travel_day_number": "Reisetag {{number}}",
|
||||||
"departure": "Start-Hafen (Reise von)",
|
"departure": "Start-Hafen (Reise von)",
|
||||||
"destination": "Ziel-Hafen (nach)",
|
"destination": "Ziel-Hafen (nach)",
|
||||||
"route": "Reise von/nach",
|
"route": "Reise von/nach",
|
||||||
@@ -197,6 +219,94 @@
|
|||||||
"saving": "Wird gespeichert...",
|
"saving": "Wird gespeichert...",
|
||||||
"saved": "Logbuchseite erfolgreich gespeichert!",
|
"saved": "Logbuchseite erfolgreich gespeichert!",
|
||||||
"loading": "Journal wird geladen...",
|
"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_entry": "Tag löschen",
|
||||||
"delete_confirm": "Bist du sicher, dass du diesen Reisetag unwiderruflich löschen möchtest?",
|
"delete_confirm": "Bist du sicher, dass du diesen Reisetag unwiderruflich löschen möchtest?",
|
||||||
"carry_over_tanks_title": "Daten vom Vortag übernehmen?",
|
"carry_over_tanks_title": "Daten vom Vortag übernehmen?",
|
||||||
@@ -273,6 +383,56 @@
|
|||||||
"track_map_end": "Ziel",
|
"track_map_end": "Ziel",
|
||||||
"track_map_speed_slow": "langsam",
|
"track_map_speed_slow": "langsam",
|
||||||
"track_map_speed_fast": "schnell",
|
"track_map_speed_fast": "schnell",
|
||||||
|
"nmea_import_title": "NMEA-Protokoll importieren",
|
||||||
|
"nmea_import_intro": "Lade eine .nmea-Datei vom Bord-Logger. Die App schlägt Journal-Einträge vor — du entscheidest, was übernommen wird.",
|
||||||
|
"nmea_import_btn": "NMEA importieren",
|
||||||
|
"nmea_file_label": "NMEA-Datei",
|
||||||
|
"nmea_stats": "{{lines}} Sätze erkannt · Typen: {{types}}",
|
||||||
|
"nmea_warn_no_position": "Keine Positions-Sätze gefunden — Track und GPS-Felder können leer bleiben.",
|
||||||
|
"nmea_warn_duplicate_file": "Diese NMEA-Datei wurde bereits importiert. Ein erneuter Import derselben Datei fügt doppelte Journal-Einträge hinzu.",
|
||||||
|
"nmea_mode_label": "Journal-Einträge erzeugen",
|
||||||
|
"nmea_mode_interval": "Nach Zeitintervall",
|
||||||
|
"nmea_mode_change": "Bei signifikanter Änderung",
|
||||||
|
"nmea_mode_both": "Beides (zusammenführen)",
|
||||||
|
"nmea_interval_label": "Intervall (Minuten)",
|
||||||
|
"nmea_import_track": "GPS-Track aus NMEA übernehmen",
|
||||||
|
"nmea_preview": "Vorschau",
|
||||||
|
"nmea_preview_hint": "{{count}} vorgeschlagene Journal-Einträge",
|
||||||
|
"nmea_select_all": "Alle auswählen",
|
||||||
|
"nmea_select_none": "Keine auswählen",
|
||||||
|
"nmea_source_interval": "Intervall",
|
||||||
|
"nmea_source_change": "Ereignis",
|
||||||
|
"nmea_apply": "In Journal übernehmen",
|
||||||
|
"nmea_back": "Zurück",
|
||||||
|
"nmea_cancel": "Abbrechen",
|
||||||
|
"nmea_archive_question": "Rohprotokoll lokal archivieren? (Nur auf diesem Gerät, nicht synchronisiert.)",
|
||||||
|
"nmea_archive_keep": "Archivieren",
|
||||||
|
"nmea_archive_discard": "Verwerfen",
|
||||||
|
"nmea_archive_stored": "NMEA archiviert: {{name}}",
|
||||||
|
"nmea_archive_delete_confirm": "Archiviertes NMEA-Protokoll von diesem Gerät löschen?",
|
||||||
|
"nmea_error_no_samples": "Keine verwertbaren NMEA-Sätze in der Datei.",
|
||||||
|
"nmea_error_parse": "NMEA-Datei konnte nicht gelesen werden.",
|
||||||
|
"nmea_error_read": "Datei konnte nicht gelesen werden.",
|
||||||
|
"nmea_error_no_file": "Bitte zuerst eine NMEA-Datei wählen.",
|
||||||
|
"nmea_error_no_selection": "Bitte mindestens einen Journal-Eintrag auswählen.",
|
||||||
|
"nmea_remark_interval": "NMEA Intervall",
|
||||||
|
"nmea_remark_uncertain": "unsicher",
|
||||||
|
"nmea_remark_depth": "Tiefe {{depth}} m",
|
||||||
|
"nmea_change_course": "Kursänderung {{from}}° → {{to}}°",
|
||||||
|
"nmea_change_wind": "Wind {{from}}° → {{to}}°",
|
||||||
|
"nmea_change_wind_speed": "Wind {{from}} → {{to}} kn",
|
||||||
|
"nmea_change_pressure": "Luftdruck {{from}} → {{to}} hPa",
|
||||||
|
"nmea_change_depth": "Tiefe {{from}} → {{to}} m",
|
||||||
|
"nmea_change_engine_start": "Motor an ({{rpm}} U/min)",
|
||||||
|
"nmea_change_engine_stop": "Motor aus",
|
||||||
|
"nmea_change_autopilot_on": "Autopilot ein",
|
||||||
|
"nmea_change_autopilot_off": "Autopilot aus",
|
||||||
|
"nmea_change_gps_lost": "GPS-Fix verloren",
|
||||||
|
"nmea_change_gps_regained": "GPS-Fix wiederhergestellt",
|
||||||
|
"nmea_change_water_temp": "Wassertemp. {{from}} → {{to}} °C",
|
||||||
|
"nmea_change_departure": "Abfahrt / Fahrtbeginn",
|
||||||
|
"nmea_change_anchor": "Ankern / Stop",
|
||||||
|
"nmea_change_speed": "Geschw. {{from}} → {{to}} kn",
|
||||||
"track_map_error": "Karte konnte nicht geladen werden.",
|
"track_map_error": "Karte konnte nicht geladen werden.",
|
||||||
"exporting": "Exportiere...",
|
"exporting": "Exportiere...",
|
||||||
"share_unsupported": "Teilen wird auf diesem Gerät nicht unterstützt. Datei wurde stattdessen heruntergeladen.",
|
"share_unsupported": "Teilen wird auf diesem Gerät nicht unterstützt. Datei wurde stattdessen heruntergeladen.",
|
||||||
@@ -308,6 +468,7 @@
|
|||||||
"role_read": "Nur Lesen",
|
"role_read": "Nur Lesen",
|
||||||
"role_read_hint": "Geteiltes Logbuch — nur Ansicht, keine Bearbeitung",
|
"role_read_hint": "Geteiltes Logbuch — nur Ansicht, keine Bearbeitung",
|
||||||
"open_profile": "Profil von {{name}} öffnen",
|
"open_profile": "Profil von {{name}} öffnen",
|
||||||
|
"open_logbook": "Logbuch „{{title}}“ öffnen",
|
||||||
"edit_title": "Logbuch umbenennen",
|
"edit_title": "Logbuch umbenennen",
|
||||||
"edit_placeholder": "Neuer Name des Logbuchs",
|
"edit_placeholder": "Neuer Name des Logbuchs",
|
||||||
"edit_success": "Logbuch erfolgreich umbenannt",
|
"edit_success": "Logbuch erfolgreich umbenannt",
|
||||||
@@ -495,6 +656,8 @@
|
|||||||
"share_enable": "Öffentlichen Link aktivieren",
|
"share_enable": "Öffentlichen Link aktivieren",
|
||||||
"share_copied": "Link kopiert!",
|
"share_copied": "Link kopiert!",
|
||||||
"share_copy_btn": "Link kopieren",
|
"share_copy_btn": "Link kopieren",
|
||||||
|
"link_qr_hint": "QR-Code zum Scannen mit dem Smartphone",
|
||||||
|
"link_qr_alt": "QR-Code für den Link",
|
||||||
"danger_zone_title": "Gefahrenzone",
|
"danger_zone_title": "Gefahrenzone",
|
||||||
"danger_zone_desc": "Durch das Löschen deines Kontos werden alle deine Passkeys, Logbücher, Schiffsdaten, Crew-Profile, Reiseeinträge und E2E-Schlüssel unwiderruflich gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
|
"danger_zone_desc": "Durch das Löschen deines Kontos werden alle deine Passkeys, Logbücher, Schiffsdaten, Crew-Profile, Reiseeinträge und E2E-Schlüssel unwiderruflich gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||||
"delete_account_btn": "Konto unwiderruflich löschen",
|
"delete_account_btn": "Konto unwiderruflich löschen",
|
||||||
@@ -663,7 +826,13 @@
|
|||||||
"unit_l": "L",
|
"unit_l": "L",
|
||||||
"day_label": "Tag {{day}}",
|
"day_label": "Tag {{day}}",
|
||||||
"account_logbooks": "Logbücher im Überblick",
|
"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": {
|
"tour": {
|
||||||
"skip": "Tour überspringen",
|
"skip": "Tour überspringen",
|
||||||
|
|||||||
@@ -13,6 +13,17 @@
|
|||||||
"sv": "Svenska",
|
"sv": "Svenska",
|
||||||
"nb": "Norsk"
|
"nb": "Norsk"
|
||||||
},
|
},
|
||||||
|
"dialog": {
|
||||||
|
"ok": "OK",
|
||||||
|
"yes": "Yes",
|
||||||
|
"no": "No"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"load_failed": "Could not load data.",
|
||||||
|
"save_failed": "Could not save changes.",
|
||||||
|
"delete_failed": "Could not delete.",
|
||||||
|
"export_failed": "Export failed."
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"unsaved_changes_title": "Unsaved changes",
|
"unsaved_changes_title": "Unsaved changes",
|
||||||
"unsaved_changes_message": "You have unsaved changes. Leave this page anyway? Your changes will be lost.",
|
"unsaved_changes_message": "You have unsaved changes. Leave this page anyway? Your changes will be lost.",
|
||||||
@@ -68,7 +79,12 @@
|
|||||||
"enter_pin_placeholder": "Enter your PIN...",
|
"enter_pin_placeholder": "Enter your PIN...",
|
||||||
"decrypt_with_pin": "Decrypt",
|
"decrypt_with_pin": "Decrypt",
|
||||||
"use_recovery_instead": "Use recovery phrase instead",
|
"use_recovery_instead": "Use recovery phrase instead",
|
||||||
"error_incorrect_pin": "Incorrect PIN. Decryption failed."
|
"error_incorrect_pin": "Incorrect PIN. Decryption failed.",
|
||||||
|
"error_invalid_host": "Passkeys do not work on 127.0.0.1. Please open the app via localhost.",
|
||||||
|
"use_localhost_link": "Switch to localhost",
|
||||||
|
"error_passkey_cancelled": "Passkey sign-in was cancelled or timed out. Please try again.",
|
||||||
|
"error_invalid_rp_id": "Passkey domain mismatch (RP ID). For local dev use http://localhost:5173 with RP_ID=localhost in .env.",
|
||||||
|
"error_session_incomplete": "Sign-in incomplete. Please sign in with your passkey again."
|
||||||
},
|
},
|
||||||
"pwa": {
|
"pwa": {
|
||||||
"title": "Install app",
|
"title": "Install app",
|
||||||
@@ -87,13 +103,18 @@
|
|||||||
"update_title": "Update available",
|
"update_title": "Update available",
|
||||||
"update_desc": "A new version of Kapteins Daagbok is ready. Reload to get the latest changes.",
|
"update_desc": "A new version of Kapteins Daagbok is ready. Reload to get the latest changes.",
|
||||||
"update_now": "Reload now",
|
"update_now": "Reload now",
|
||||||
"update_reloading": "Reloading…"
|
"update_reloading": "Reloading…",
|
||||||
|
"storage_persist_hint": "Your browser may delete offline data. Allow persistent storage to keep your logbook safe (browser settings or when prompted)."
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
"status_synced": "Synced",
|
"status_synced": "Synced",
|
||||||
"status_syncing": "Syncing…",
|
"status_syncing": "Syncing…",
|
||||||
"status_offline": "Offline Cache",
|
"status_offline": "Offline Cache",
|
||||||
"status_unsynced": "Unsynced changes"
|
"status_unsynced": "Unsynced changes",
|
||||||
|
"conflict_title": "Sync conflict",
|
||||||
|
"conflict_message": "{{count}} change(s) could not be synced (entry {{id}}…). Choose which version to keep.",
|
||||||
|
"conflict_use_server": "Use server version",
|
||||||
|
"conflict_keep_local": "Keep my version"
|
||||||
},
|
},
|
||||||
"vessel": {
|
"vessel": {
|
||||||
"title": "Vessel Master Data",
|
"title": "Vessel Master Data",
|
||||||
@@ -145,7 +166,8 @@
|
|||||||
"sign_cleared_skipper_re_sign_title": "Skipper signature removed",
|
"sign_cleared_skipper_re_sign_title": "Skipper signature removed",
|
||||||
"sign_cleared_skipper_re_sign": "The event log was changed. The skipper signature was removed. Please sign again.",
|
"sign_cleared_skipper_re_sign": "The event log was changed. The skipper signature was removed. Please sign again.",
|
||||||
"date": "Date",
|
"date": "Date",
|
||||||
"day_of_travel": "Day of Travel",
|
"day_of_travel": "Travel day",
|
||||||
|
"travel_day_number": "Travel day {{number}}",
|
||||||
"departure": "Departure Port (von)",
|
"departure": "Departure Port (von)",
|
||||||
"destination": "Destination Port (nach)",
|
"destination": "Destination Port (nach)",
|
||||||
"route": "Route / Journey",
|
"route": "Route / Journey",
|
||||||
@@ -197,6 +219,94 @@
|
|||||||
"saving": "Saving...",
|
"saving": "Saving...",
|
||||||
"saved": "Logbook page saved successfully!",
|
"saved": "Logbook page saved successfully!",
|
||||||
"loading": "Loading journal...",
|
"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_entry": "Delete Day",
|
||||||
"delete_confirm": "Are you sure you want to permanently delete this travel day?",
|
"delete_confirm": "Are you sure you want to permanently delete this travel day?",
|
||||||
"carry_over_tanks_title": "Carry over from previous day?",
|
"carry_over_tanks_title": "Carry over from previous day?",
|
||||||
@@ -273,6 +383,56 @@
|
|||||||
"track_map_end": "End",
|
"track_map_end": "End",
|
||||||
"track_map_speed_slow": "slow",
|
"track_map_speed_slow": "slow",
|
||||||
"track_map_speed_fast": "fast",
|
"track_map_speed_fast": "fast",
|
||||||
|
"nmea_import_title": "Import NMEA log",
|
||||||
|
"nmea_import_intro": "Upload a .nmea file from your onboard logger. The app suggests journal entries — you choose what to import.",
|
||||||
|
"nmea_import_btn": "Import NMEA",
|
||||||
|
"nmea_file_label": "NMEA file",
|
||||||
|
"nmea_stats": "{{lines}} sentences parsed · types: {{types}}",
|
||||||
|
"nmea_warn_no_position": "No position sentences found — track and GPS fields may stay empty.",
|
||||||
|
"nmea_warn_duplicate_file": "This NMEA file has already been imported. Importing the same file again will add duplicate journal entries.",
|
||||||
|
"nmea_mode_label": "Generate journal entries",
|
||||||
|
"nmea_mode_interval": "By time interval",
|
||||||
|
"nmea_mode_change": "On significant change",
|
||||||
|
"nmea_mode_both": "Both (merge)",
|
||||||
|
"nmea_interval_label": "Interval (minutes)",
|
||||||
|
"nmea_import_track": "Import GPS track from NMEA",
|
||||||
|
"nmea_preview": "Preview",
|
||||||
|
"nmea_preview_hint": "{{count}} suggested journal entries",
|
||||||
|
"nmea_select_all": "Select all",
|
||||||
|
"nmea_select_none": "Select none",
|
||||||
|
"nmea_source_interval": "Interval",
|
||||||
|
"nmea_source_change": "Event",
|
||||||
|
"nmea_apply": "Apply to journal",
|
||||||
|
"nmea_back": "Back",
|
||||||
|
"nmea_cancel": "Cancel",
|
||||||
|
"nmea_archive_question": "Archive raw log locally? (This device only, not synced.)",
|
||||||
|
"nmea_archive_keep": "Archive",
|
||||||
|
"nmea_archive_discard": "Discard",
|
||||||
|
"nmea_archive_stored": "NMEA archived: {{name}}",
|
||||||
|
"nmea_archive_delete_confirm": "Delete archived NMEA log from this device?",
|
||||||
|
"nmea_error_no_samples": "No usable NMEA sentences in the file.",
|
||||||
|
"nmea_error_parse": "Could not read NMEA file.",
|
||||||
|
"nmea_error_read": "Could not read file.",
|
||||||
|
"nmea_error_no_file": "Please choose an NMEA file first.",
|
||||||
|
"nmea_error_no_selection": "Please select at least one journal entry.",
|
||||||
|
"nmea_remark_interval": "NMEA interval",
|
||||||
|
"nmea_remark_uncertain": "uncertain",
|
||||||
|
"nmea_remark_depth": "Depth {{depth}} m",
|
||||||
|
"nmea_change_course": "Course change {{from}}° → {{to}}°",
|
||||||
|
"nmea_change_wind": "Wind {{from}}° → {{to}}°",
|
||||||
|
"nmea_change_wind_speed": "Wind {{from}} → {{to}} kn",
|
||||||
|
"nmea_change_pressure": "Pressure {{from}} → {{to}} hPa",
|
||||||
|
"nmea_change_depth": "Depth {{from}} → {{to}} m",
|
||||||
|
"nmea_change_engine_start": "Engine on ({{rpm}} rpm)",
|
||||||
|
"nmea_change_engine_stop": "Engine off",
|
||||||
|
"nmea_change_autopilot_on": "Autopilot on",
|
||||||
|
"nmea_change_autopilot_off": "Autopilot off",
|
||||||
|
"nmea_change_gps_lost": "GPS fix lost",
|
||||||
|
"nmea_change_gps_regained": "GPS fix restored",
|
||||||
|
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
|
||||||
|
"nmea_change_departure": "Departure / underway",
|
||||||
|
"nmea_change_anchor": "Anchored / stop",
|
||||||
|
"nmea_change_speed": "Speed {{from}} → {{to}} kn",
|
||||||
"track_map_error": "Could not load map.",
|
"track_map_error": "Could not load map.",
|
||||||
"exporting": "Exporting...",
|
"exporting": "Exporting...",
|
||||||
"share_unsupported": "Web sharing is not supported on this device. File downloaded instead.",
|
"share_unsupported": "Web sharing is not supported on this device. File downloaded instead.",
|
||||||
@@ -308,6 +468,7 @@
|
|||||||
"role_read": "Read only",
|
"role_read": "Read only",
|
||||||
"role_read_hint": "Shared logbook — view only, no editing",
|
"role_read_hint": "Shared logbook — view only, no editing",
|
||||||
"open_profile": "Open profile for {{name}}",
|
"open_profile": "Open profile for {{name}}",
|
||||||
|
"open_logbook": "Open logbook “{{title}}”",
|
||||||
"edit_title": "Rename Logbook",
|
"edit_title": "Rename Logbook",
|
||||||
"edit_placeholder": "New name of the logbook",
|
"edit_placeholder": "New name of the logbook",
|
||||||
"edit_success": "Logbook renamed successfully",
|
"edit_success": "Logbook renamed successfully",
|
||||||
@@ -495,6 +656,8 @@
|
|||||||
"share_enable": "Enable Public Link",
|
"share_enable": "Enable Public Link",
|
||||||
"share_copied": "Link copied!",
|
"share_copied": "Link copied!",
|
||||||
"share_copy_btn": "Copy Link",
|
"share_copy_btn": "Copy Link",
|
||||||
|
"link_qr_hint": "Scan this QR code with your phone",
|
||||||
|
"link_qr_alt": "QR code for the link",
|
||||||
"danger_zone_title": "Danger Zone",
|
"danger_zone_title": "Danger Zone",
|
||||||
"danger_zone_desc": "Deleting your account will permanently delete all your passkeys, logbooks, vessel data, crew profiles, travel logs, and E2E keys. This action cannot be undone.",
|
"danger_zone_desc": "Deleting your account will permanently delete all your passkeys, logbooks, vessel data, crew profiles, travel logs, and E2E keys. This action cannot be undone.",
|
||||||
"delete_account_btn": "Permanently Delete Account",
|
"delete_account_btn": "Permanently Delete Account",
|
||||||
@@ -663,7 +826,13 @@
|
|||||||
"unit_l": "L",
|
"unit_l": "L",
|
||||||
"day_label": "Day {{day}}",
|
"day_label": "Day {{day}}",
|
||||||
"account_logbooks": "Logbooks overview",
|
"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": {
|
"tour": {
|
||||||
"skip": "Skip tour",
|
"skip": "Skip tour",
|
||||||
|
|||||||
@@ -13,6 +13,17 @@
|
|||||||
"sv": "Svenska",
|
"sv": "Svenska",
|
||||||
"nb": "Norsk"
|
"nb": "Norsk"
|
||||||
},
|
},
|
||||||
|
"dialog": {
|
||||||
|
"ok": "OK",
|
||||||
|
"yes": "Ja",
|
||||||
|
"no": "Nei"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"load_failed": "Data kunne ikke lastes.",
|
||||||
|
"save_failed": "Endringer kunne ikke lagres.",
|
||||||
|
"delete_failed": "Sletting mislyktes.",
|
||||||
|
"export_failed": "Eksport mislyktes."
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"unsaved_changes_title": "Ikke-lagrede endringer",
|
"unsaved_changes_title": "Ikke-lagrede endringer",
|
||||||
"unsaved_changes_message": "Du har endringer som ikke er lagret. Vil du virkelig forlate siden? Endringene dine vil gå tapt.",
|
"unsaved_changes_message": "Du har endringer som ikke er lagret. Vil du virkelig forlate siden? Endringene dine vil gå tapt.",
|
||||||
@@ -68,7 +79,12 @@
|
|||||||
"enter_pin_placeholder": "Tast inn PIN-koden din...",
|
"enter_pin_placeholder": "Tast inn PIN-koden din...",
|
||||||
"decrypt_with_pin": "Dekryptere",
|
"decrypt_with_pin": "Dekryptere",
|
||||||
"use_recovery_instead": "Bruk gjenopprettingsnøkler i stedet",
|
"use_recovery_instead": "Bruk gjenopprettingsnøkler i stedet",
|
||||||
"error_incorrect_pin": "Feil PIN-kode. Dekryptering mislyktes."
|
"error_incorrect_pin": "Feil PIN-kode. Dekryptering mislyktes.",
|
||||||
|
"error_invalid_host": "Passkeys fungerer ikke via 127.0.0.1. Åpne appen via localhost.",
|
||||||
|
"use_localhost_link": "Bytt til localhost",
|
||||||
|
"error_passkey_cancelled": "Passkey-innlogging ble avbrutt eller utløp. Prøv igjen.",
|
||||||
|
"error_invalid_rp_id": "Passkey-domene stemmer ikke (RP ID). Bruk http://localhost:5173 med RP_ID=localhost i .env for lokal utvikling.",
|
||||||
|
"error_session_incomplete": "Innlogging ufullstendig. Logg inn med passkey igjen."
|
||||||
},
|
},
|
||||||
"pwa": {
|
"pwa": {
|
||||||
"title": "Installer app",
|
"title": "Installer app",
|
||||||
@@ -87,13 +103,18 @@
|
|||||||
"update_title": "Oppdatering tilgjengelig",
|
"update_title": "Oppdatering tilgjengelig",
|
||||||
"update_desc": "En ny versjon av Kapteins Daagbok er klar. Oppdater for å få med de siste endringene.",
|
"update_desc": "En ny versjon av Kapteins Daagbok er klar. Oppdater for å få med de siste endringene.",
|
||||||
"update_now": "Oppdater nå",
|
"update_now": "Oppdater nå",
|
||||||
"update_reloading": "Laster..."
|
"update_reloading": "Laster...",
|
||||||
|
"storage_persist_hint": "Nettleseren kan slette offlinedata. Tillat permanent lagring slik at loggboken din forblir beskyttet."
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
"status_synced": "Synkronisert",
|
"status_synced": "Synkronisert",
|
||||||
"status_syncing": "Synkroniser...",
|
"status_syncing": "Synkroniser...",
|
||||||
"status_offline": "Frakoblet hurtigbuffer",
|
"status_offline": "Frakoblet hurtigbuffer",
|
||||||
"status_unsynced": "Usynkroniserte endringer"
|
"status_unsynced": "Usynkroniserte endringer",
|
||||||
|
"conflict_title": "Synkroniseringskonflikt",
|
||||||
|
"conflict_message": "{{count}} endring(er) kunne ikke synkroniseres (post {{id}}…). Velg hvilken versjon som skal gjelde.",
|
||||||
|
"conflict_use_server": "Bruk serverversjon",
|
||||||
|
"conflict_keep_local": "Behold min versjon"
|
||||||
},
|
},
|
||||||
"vessel": {
|
"vessel": {
|
||||||
"title": "Stamdata for skip",
|
"title": "Stamdata for skip",
|
||||||
@@ -145,7 +166,8 @@
|
|||||||
"sign_cleared_skipper_re_sign_title": "Skippers signatur fjernet",
|
"sign_cleared_skipper_re_sign_title": "Skippers signatur fjernet",
|
||||||
"sign_cleared_skipper_re_sign": "Hendelsesloggen har blitt endret. Skipperens signatur er fjernet. Vennligst godkjenn på nytt.",
|
"sign_cleared_skipper_re_sign": "Hendelsesloggen har blitt endret. Skipperens signatur er fjernet. Vennligst godkjenn på nytt.",
|
||||||
"date": "dato",
|
"date": "dato",
|
||||||
"day_of_travel": "Reisens dag / reisedag",
|
"day_of_travel": "Reisedag",
|
||||||
|
"travel_day_number": "Reisedag {{number}}",
|
||||||
"departure": "Starthavn (reise fra)",
|
"departure": "Starthavn (reise fra)",
|
||||||
"destination": "Destinasjonsport (til)",
|
"destination": "Destinasjonsport (til)",
|
||||||
"route": "Reise fra/til",
|
"route": "Reise fra/til",
|
||||||
@@ -197,6 +219,94 @@
|
|||||||
"saving": "...vil bli reddet...",
|
"saving": "...vil bli reddet...",
|
||||||
"saved": "Loggboksiden er vellykket lagret!",
|
"saved": "Loggboksiden er vellykket lagret!",
|
||||||
"loading": "Tidsskriftet lastes inn...",
|
"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_entry": "Slett tagg",
|
||||||
"delete_confirm": "Er du sikker på at du vil slette denne reisedagen permanent?",
|
"delete_confirm": "Er du sikker på at du vil slette denne reisedagen permanent?",
|
||||||
"carry_over_tanks_title": "Overføre data fra dagen før?",
|
"carry_over_tanks_title": "Overføre data fra dagen før?",
|
||||||
@@ -283,7 +393,57 @@
|
|||||||
"revoke": "Fjern",
|
"revoke": "Fjern",
|
||||||
"revoke_confirm": "Er du sikker på at du vil oppheve dette besetningsmedlemmets tilgang?",
|
"revoke_confirm": "Er du sikker på at du vil oppheve dette besetningsmedlemmets tilgang?",
|
||||||
"invite_role": "Rolle",
|
"invite_role": "Rolle",
|
||||||
"invite_expires": "Lenken er gyldig i 48 timer"
|
"invite_expires": "Lenken er gyldig i 48 timer",
|
||||||
|
"nmea_import_title": "Import NMEA log",
|
||||||
|
"nmea_import_intro": "Upload a .nmea file from your onboard logger. The app suggests journal entries — you choose what to import.",
|
||||||
|
"nmea_import_btn": "Import NMEA",
|
||||||
|
"nmea_file_label": "NMEA file",
|
||||||
|
"nmea_stats": "{{lines}} sentences parsed · types: {{types}}",
|
||||||
|
"nmea_warn_no_position": "No position sentences found — track and GPS fields may stay empty.",
|
||||||
|
"nmea_mode_label": "Generate journal entries",
|
||||||
|
"nmea_mode_interval": "By time interval",
|
||||||
|
"nmea_mode_change": "On significant change",
|
||||||
|
"nmea_mode_both": "Both (merge)",
|
||||||
|
"nmea_interval_label": "Interval (minutes)",
|
||||||
|
"nmea_import_track": "Import GPS track from NMEA",
|
||||||
|
"nmea_preview": "Preview",
|
||||||
|
"nmea_preview_hint": "{{count}} suggested journal entries",
|
||||||
|
"nmea_select_all": "Select all",
|
||||||
|
"nmea_select_none": "Select none",
|
||||||
|
"nmea_source_interval": "Interval",
|
||||||
|
"nmea_source_change": "Event",
|
||||||
|
"nmea_apply": "Apply to journal",
|
||||||
|
"nmea_back": "Back",
|
||||||
|
"nmea_cancel": "Cancel",
|
||||||
|
"nmea_archive_question": "Archive raw log locally? (This device only, not synced.)",
|
||||||
|
"nmea_archive_keep": "Archive",
|
||||||
|
"nmea_archive_discard": "Discard",
|
||||||
|
"nmea_archive_stored": "NMEA archived: {{name}}",
|
||||||
|
"nmea_archive_delete_confirm": "Delete archived NMEA log from this device?",
|
||||||
|
"nmea_error_no_samples": "No usable NMEA sentences in the file.",
|
||||||
|
"nmea_error_parse": "Could not read NMEA file.",
|
||||||
|
"nmea_error_read": "Could not read file.",
|
||||||
|
"nmea_error_no_file": "Please choose an NMEA file first.",
|
||||||
|
"nmea_error_no_selection": "Please select at least one journal entry.",
|
||||||
|
"nmea_remark_interval": "NMEA interval",
|
||||||
|
"nmea_remark_uncertain": "uncertain",
|
||||||
|
"nmea_remark_depth": "Depth {{depth}} m",
|
||||||
|
"nmea_change_course": "Course change {{from}}° → {{to}}°",
|
||||||
|
"nmea_change_wind": "Wind {{from}}° → {{to}}°",
|
||||||
|
"nmea_change_wind_speed": "Wind {{from}} → {{to}} kn",
|
||||||
|
"nmea_change_pressure": "Pressure {{from}} → {{to}} hPa",
|
||||||
|
"nmea_change_depth": "Depth {{from}} → {{to}} m",
|
||||||
|
"nmea_change_engine_start": "Engine on ({{rpm}} rpm)",
|
||||||
|
"nmea_change_engine_stop": "Engine off",
|
||||||
|
"nmea_change_autopilot_on": "Autopilot on",
|
||||||
|
"nmea_change_autopilot_off": "Autopilot off",
|
||||||
|
"nmea_change_gps_lost": "GPS fix lost",
|
||||||
|
"nmea_change_gps_regained": "GPS fix restored",
|
||||||
|
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
|
||||||
|
"nmea_change_departure": "Departure / underway",
|
||||||
|
"nmea_change_anchor": "Anchored / stop",
|
||||||
|
"nmea_change_speed": "Speed {{from}} → {{to}} kn",
|
||||||
|
"nmea_warn_duplicate_file": "This NMEA file has already been imported. Importing the same file again will add duplicate journal entries."
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Loggbøkene dine",
|
"title": "Loggbøkene dine",
|
||||||
@@ -308,6 +468,7 @@
|
|||||||
"role_read": "Bare les",
|
"role_read": "Bare les",
|
||||||
"role_read_hint": "Delt loggbok - kun visning, ingen redigering",
|
"role_read_hint": "Delt loggbok - kun visning, ingen redigering",
|
||||||
"open_profile": "Åpne profilen til {{name}}",
|
"open_profile": "Åpne profilen til {{name}}",
|
||||||
|
"open_logbook": "Åpne loggbok «{{title}}»",
|
||||||
"edit_title": "Endre navn på loggbok",
|
"edit_title": "Endre navn på loggbok",
|
||||||
"edit_placeholder": "Nytt navn på loggboken",
|
"edit_placeholder": "Nytt navn på loggboken",
|
||||||
"edit_success": "Loggboken har fått nytt navn",
|
"edit_success": "Loggboken har fått nytt navn",
|
||||||
@@ -495,6 +656,8 @@
|
|||||||
"share_enable": "Aktiver offentlig lenke",
|
"share_enable": "Aktiver offentlig lenke",
|
||||||
"share_copied": "Linken er kopiert!",
|
"share_copied": "Linken er kopiert!",
|
||||||
"share_copy_btn": "Kopier lenke",
|
"share_copy_btn": "Kopier lenke",
|
||||||
|
"link_qr_hint": "Skann QR-koden med telefonen",
|
||||||
|
"link_qr_alt": "QR-kode for lenken",
|
||||||
"danger_zone_title": "Faresone",
|
"danger_zone_title": "Faresone",
|
||||||
"danger_zone_desc": "Hvis du sletter kontoen din, slettes alle dine Passkeys, loggbøker, skipsdata, mannskapsprofiler, reiseoppføringer og E2E-nøkler ugjenkallelig. Denne handlingen kan ikke angres.",
|
"danger_zone_desc": "Hvis du sletter kontoen din, slettes alle dine Passkeys, loggbøker, skipsdata, mannskapsprofiler, reiseoppføringer og E2E-nøkler ugjenkallelig. Denne handlingen kan ikke angres.",
|
||||||
"delete_account_btn": "Slett konto ugjenkallelig",
|
"delete_account_btn": "Slett konto ugjenkallelig",
|
||||||
@@ -663,7 +826,13 @@
|
|||||||
"unit_l": "L",
|
"unit_l": "L",
|
||||||
"day_label": "Dag {{day}}",
|
"day_label": "Dag {{day}}",
|
||||||
"account_logbooks": "Oversikt over loggbøker",
|
"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": {
|
"tour": {
|
||||||
"skip": "Hopp over turen",
|
"skip": "Hopp over turen",
|
||||||
|
|||||||
@@ -13,6 +13,17 @@
|
|||||||
"sv": "Svenska",
|
"sv": "Svenska",
|
||||||
"nb": "Norsk"
|
"nb": "Norsk"
|
||||||
},
|
},
|
||||||
|
"dialog": {
|
||||||
|
"ok": "OK",
|
||||||
|
"yes": "Ja",
|
||||||
|
"no": "Nej"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"load_failed": "Data kunde inte laddas.",
|
||||||
|
"save_failed": "Ändringar kunde inte sparas.",
|
||||||
|
"delete_failed": "Radering misslyckades.",
|
||||||
|
"export_failed": "Export misslyckades."
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"unsaved_changes_title": "Osparade ändringar",
|
"unsaved_changes_title": "Osparade ändringar",
|
||||||
"unsaved_changes_message": "Du har ändringar som inte sparats. Vill du verkligen lämna sidan? Dina ändringar kommer att gå förlorade.",
|
"unsaved_changes_message": "Du har ändringar som inte sparats. Vill du verkligen lämna sidan? Dina ändringar kommer att gå förlorade.",
|
||||||
@@ -68,7 +79,12 @@
|
|||||||
"enter_pin_placeholder": "Ange din PIN-kod...",
|
"enter_pin_placeholder": "Ange din PIN-kod...",
|
||||||
"decrypt_with_pin": "Dekryptera",
|
"decrypt_with_pin": "Dekryptera",
|
||||||
"use_recovery_instead": "Använd återställningsnycklar istället",
|
"use_recovery_instead": "Använd återställningsnycklar istället",
|
||||||
"error_incorrect_pin": "Felaktig PIN-kod. Dekryptering misslyckades."
|
"error_incorrect_pin": "Felaktig PIN-kod. Dekryptering misslyckades.",
|
||||||
|
"error_invalid_host": "Passkeys fungerar inte via 127.0.0.1. Öppna appen via localhost.",
|
||||||
|
"use_localhost_link": "Byt till localhost",
|
||||||
|
"error_passkey_cancelled": "Passkey-inloggning avbröts eller gick ut. Försök igen.",
|
||||||
|
"error_invalid_rp_id": "Passkey-domänen matchar inte (RP ID). Använd http://localhost:5173 med RP_ID=localhost i .env för lokal utveckling.",
|
||||||
|
"error_session_incomplete": "Inloggning ofullständig. Logga in med passkey igen."
|
||||||
},
|
},
|
||||||
"pwa": {
|
"pwa": {
|
||||||
"title": "Installera app",
|
"title": "Installera app",
|
||||||
@@ -87,13 +103,18 @@
|
|||||||
"update_title": "Uppdatering tillgänglig",
|
"update_title": "Uppdatering tillgänglig",
|
||||||
"update_desc": "En ny version av Kapteins Daagbok är klar. Uppdatera för att få de senaste ändringarna.",
|
"update_desc": "En ny version av Kapteins Daagbok är klar. Uppdatera för att få de senaste ändringarna.",
|
||||||
"update_now": "Uppdatering nu",
|
"update_now": "Uppdatering nu",
|
||||||
"update_reloading": "Laddar..."
|
"update_reloading": "Laddar...",
|
||||||
|
"storage_persist_hint": "Webbläsaren kan radera offlinedata. Tillåt permanent lagring så att din loggbok förblir skyddad."
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
"status_synced": "Synkroniserad",
|
"status_synced": "Synkroniserad",
|
||||||
"status_syncing": "Synkronisera...",
|
"status_syncing": "Synkronisera...",
|
||||||
"status_offline": "Offline-cache",
|
"status_offline": "Offline-cache",
|
||||||
"status_unsynced": "Osynkroniserade förändringar"
|
"status_unsynced": "Osynkroniserade förändringar",
|
||||||
|
"conflict_title": "Synkroniseringskonflikt",
|
||||||
|
"conflict_message": "{{count}} ändring(ar) kunde inte synkas (post {{id}}…). Välj vilken version som ska gälla.",
|
||||||
|
"conflict_use_server": "Använd serverversion",
|
||||||
|
"conflict_keep_local": "Behåll min version"
|
||||||
},
|
},
|
||||||
"vessel": {
|
"vessel": {
|
||||||
"title": "Masterdata för fartyg",
|
"title": "Masterdata för fartyg",
|
||||||
@@ -145,7 +166,8 @@
|
|||||||
"sign_cleared_skipper_re_sign_title": "Skippers signatur borttagen",
|
"sign_cleared_skipper_re_sign_title": "Skippers signatur borttagen",
|
||||||
"sign_cleared_skipper_re_sign": "Händelseloggen har ändrats. Skepparens signatur har tagits bort. Vänligen godkänn igen.",
|
"sign_cleared_skipper_re_sign": "Händelseloggen har ändrats. Skepparens signatur har tagits bort. Vänligen godkänn igen.",
|
||||||
"date": "datum",
|
"date": "datum",
|
||||||
"day_of_travel": "Resedag / resedag",
|
"day_of_travel": "Resedag",
|
||||||
|
"travel_day_number": "Resedag {{number}}",
|
||||||
"departure": "Starthamn (resa från)",
|
"departure": "Starthamn (resa från)",
|
||||||
"destination": "Destinationsport (till)",
|
"destination": "Destinationsport (till)",
|
||||||
"route": "Resa från/till",
|
"route": "Resa från/till",
|
||||||
@@ -197,6 +219,94 @@
|
|||||||
"saving": "Kommer att sparas...",
|
"saving": "Kommer att sparas...",
|
||||||
"saved": "Loggbokssidan har sparats framgångsrikt!",
|
"saved": "Loggbokssidan har sparats framgångsrikt!",
|
||||||
"loading": "Journalen laddas...",
|
"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_entry": "Ta bort tagg",
|
||||||
"delete_confirm": "Är du säker på att du vill radera den här resedagen permanent?",
|
"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?",
|
"carry_over_tanks_title": "Överföra data från föregående dag?",
|
||||||
@@ -283,7 +393,57 @@
|
|||||||
"revoke": "Ta bort",
|
"revoke": "Ta bort",
|
||||||
"revoke_confirm": "Är du säker på att du vill återkalla den här besättningsmedlemmens åtkomst?",
|
"revoke_confirm": "Är du säker på att du vill återkalla den här besättningsmedlemmens åtkomst?",
|
||||||
"invite_role": "Roll",
|
"invite_role": "Roll",
|
||||||
"invite_expires": "Länken är giltig i 48 timmar"
|
"invite_expires": "Länken är giltig i 48 timmar",
|
||||||
|
"nmea_import_title": "Import NMEA log",
|
||||||
|
"nmea_import_intro": "Upload a .nmea file from your onboard logger. The app suggests journal entries — you choose what to import.",
|
||||||
|
"nmea_import_btn": "Import NMEA",
|
||||||
|
"nmea_file_label": "NMEA file",
|
||||||
|
"nmea_stats": "{{lines}} sentences parsed · types: {{types}}",
|
||||||
|
"nmea_warn_no_position": "No position sentences found — track and GPS fields may stay empty.",
|
||||||
|
"nmea_mode_label": "Generate journal entries",
|
||||||
|
"nmea_mode_interval": "By time interval",
|
||||||
|
"nmea_mode_change": "On significant change",
|
||||||
|
"nmea_mode_both": "Both (merge)",
|
||||||
|
"nmea_interval_label": "Interval (minutes)",
|
||||||
|
"nmea_import_track": "Import GPS track from NMEA",
|
||||||
|
"nmea_preview": "Preview",
|
||||||
|
"nmea_preview_hint": "{{count}} suggested journal entries",
|
||||||
|
"nmea_select_all": "Select all",
|
||||||
|
"nmea_select_none": "Select none",
|
||||||
|
"nmea_source_interval": "Interval",
|
||||||
|
"nmea_source_change": "Event",
|
||||||
|
"nmea_apply": "Apply to journal",
|
||||||
|
"nmea_back": "Back",
|
||||||
|
"nmea_cancel": "Cancel",
|
||||||
|
"nmea_archive_question": "Archive raw log locally? (This device only, not synced.)",
|
||||||
|
"nmea_archive_keep": "Archive",
|
||||||
|
"nmea_archive_discard": "Discard",
|
||||||
|
"nmea_archive_stored": "NMEA archived: {{name}}",
|
||||||
|
"nmea_archive_delete_confirm": "Delete archived NMEA log from this device?",
|
||||||
|
"nmea_error_no_samples": "No usable NMEA sentences in the file.",
|
||||||
|
"nmea_error_parse": "Could not read NMEA file.",
|
||||||
|
"nmea_error_read": "Could not read file.",
|
||||||
|
"nmea_error_no_file": "Please choose an NMEA file first.",
|
||||||
|
"nmea_error_no_selection": "Please select at least one journal entry.",
|
||||||
|
"nmea_remark_interval": "NMEA interval",
|
||||||
|
"nmea_remark_uncertain": "uncertain",
|
||||||
|
"nmea_remark_depth": "Depth {{depth}} m",
|
||||||
|
"nmea_change_course": "Course change {{from}}° → {{to}}°",
|
||||||
|
"nmea_change_wind": "Wind {{from}}° → {{to}}°",
|
||||||
|
"nmea_change_wind_speed": "Wind {{from}} → {{to}} kn",
|
||||||
|
"nmea_change_pressure": "Pressure {{from}} → {{to}} hPa",
|
||||||
|
"nmea_change_depth": "Depth {{from}} → {{to}} m",
|
||||||
|
"nmea_change_engine_start": "Engine on ({{rpm}} rpm)",
|
||||||
|
"nmea_change_engine_stop": "Engine off",
|
||||||
|
"nmea_change_autopilot_on": "Autopilot on",
|
||||||
|
"nmea_change_autopilot_off": "Autopilot off",
|
||||||
|
"nmea_change_gps_lost": "GPS fix lost",
|
||||||
|
"nmea_change_gps_regained": "GPS fix restored",
|
||||||
|
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
|
||||||
|
"nmea_change_departure": "Departure / underway",
|
||||||
|
"nmea_change_anchor": "Anchored / stop",
|
||||||
|
"nmea_change_speed": "Speed {{from}} → {{to}} kn",
|
||||||
|
"nmea_warn_duplicate_file": "This NMEA file has already been imported. Importing the same file again will add duplicate journal entries."
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dina loggböcker",
|
"title": "Dina loggböcker",
|
||||||
@@ -308,6 +468,7 @@
|
|||||||
"role_read": "Endast läsning",
|
"role_read": "Endast läsning",
|
||||||
"role_read_hint": "Delad loggbok - endast visning, ingen redigering",
|
"role_read_hint": "Delad loggbok - endast visning, ingen redigering",
|
||||||
"open_profile": "Öppna profil för {{name}}",
|
"open_profile": "Öppna profil för {{name}}",
|
||||||
|
"open_logbook": "Öppna loggbok ”{{title}}”",
|
||||||
"edit_title": "Byt namn på loggbok",
|
"edit_title": "Byt namn på loggbok",
|
||||||
"edit_placeholder": "Nytt namn på loggboken",
|
"edit_placeholder": "Nytt namn på loggboken",
|
||||||
"edit_success": "Loggboken har framgångsrikt bytt namn",
|
"edit_success": "Loggboken har framgångsrikt bytt namn",
|
||||||
@@ -495,6 +656,8 @@
|
|||||||
"share_enable": "Aktivera offentlig länk",
|
"share_enable": "Aktivera offentlig länk",
|
||||||
"share_copied": "Länk kopierad!",
|
"share_copied": "Länk kopierad!",
|
||||||
"share_copy_btn": "Kopiera länk",
|
"share_copy_btn": "Kopiera länk",
|
||||||
|
"link_qr_hint": "Skanna QR-koden med mobilen",
|
||||||
|
"link_qr_alt": "QR-kod för länken",
|
||||||
"danger_zone_title": "Farlig zon",
|
"danger_zone_title": "Farlig zon",
|
||||||
"danger_zone_desc": "Om du raderar ditt konto raderas oåterkalleligen alla dina Passkey, loggböcker, fartygsdata, besättningsprofiler, reseanteckningar och E2E-nycklar. Denna åtgärd kan inte ångras.",
|
"danger_zone_desc": "Om du raderar ditt konto raderas oåterkalleligen alla dina Passkey, loggböcker, fartygsdata, besättningsprofiler, reseanteckningar och E2E-nycklar. Denna åtgärd kan inte ångras.",
|
||||||
"delete_account_btn": "Ta bort konto oåterkalleligt",
|
"delete_account_btn": "Ta bort konto oåterkalleligt",
|
||||||
@@ -663,7 +826,13 @@
|
|||||||
"unit_l": "L",
|
"unit_l": "L",
|
||||||
"day_label": "Dag {{day}}__.",
|
"day_label": "Dag {{day}}__.",
|
||||||
"account_logbooks": "Loggböcker i en överblick",
|
"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": {
|
"tour": {
|
||||||
"skip": "Hoppa över turen",
|
"skip": "Hoppa över turen",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
markReloadAttempt,
|
markReloadAttempt,
|
||||||
reconcileVersionOnStartup
|
reconcileVersionOnStartup
|
||||||
} from './services/pwaStartup.ts'
|
} from './services/pwaStartup.ts'
|
||||||
|
import { redirectToPasskeyCompatibleHostIfNeeded } from './utils/passkeyHost.ts'
|
||||||
|
|
||||||
/** Stale PWA precache on localhost can shadow Vite dev modules. */
|
/** Stale PWA precache on localhost can shadow Vite dev modules. */
|
||||||
async function clearDevServiceWorkerCaches(): Promise<void> {
|
async function clearDevServiceWorkerCaches(): Promise<void> {
|
||||||
@@ -40,6 +41,10 @@ function renderBootstrapError(message: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
|
if (redirectToPasskeyCompatibleHostIfNeeded()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
applyAppearanceToDocument()
|
applyAppearanceToDocument()
|
||||||
installStaleAssetRecovery()
|
installStaleAssetRecovery()
|
||||||
await clearDevServiceWorkerCaches()
|
await clearDevServiceWorkerCaches()
|
||||||
|
|||||||
@@ -35,9 +35,18 @@ export const PlausibleEvents = {
|
|||||||
LOCAL_PIN_REMOVED: 'Local PIN Removed',
|
LOCAL_PIN_REMOVED: 'Local PIN Removed',
|
||||||
DEVICE_FORGOTTEN: 'Device Forgotten',
|
DEVICE_FORGOTTEN: 'Device Forgotten',
|
||||||
RECOVERY_ROTATED: 'Recovery Rotated',
|
RECOVERY_ROTATED: 'Recovery Rotated',
|
||||||
LANGUAGE_CHANGED: 'Language Changed'
|
LANGUAGE_CHANGED: 'Language Changed',
|
||||||
|
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
|
} 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 PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents]
|
||||||
|
|
||||||
export type PlausibleEventProps = Record<string, string | number | boolean>
|
export type PlausibleEventProps = Record<string, string | number | boolean>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { clearLogbookKeysCache } from './logbookKeys.js'
|
|||||||
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
|
||||||
import { db } from './db.js'
|
import { db } from './db.js'
|
||||||
import { apiFetch, apiJson } from './api.js'
|
import { apiFetch, apiJson } from './api.js'
|
||||||
|
import { isWebAuthnUserAbortError } from '../utils/passkeyHost.js'
|
||||||
|
|
||||||
const API_BASE = '/api/auth'
|
const API_BASE = '/api/auth'
|
||||||
|
|
||||||
@@ -361,7 +362,11 @@ export async function loginUser(username?: string): Promise<LoginResult> {
|
|||||||
const prfRequested = !!options.extensions?.prf
|
const prfRequested = !!options.extensions?.prf
|
||||||
try {
|
try {
|
||||||
credentialResponse = await startAuthentication({ optionsJSON: options })
|
credentialResponse = await startAuthentication({ optionsJSON: options })
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
|
// User cancelled or timed out — never open a second platform prompt.
|
||||||
|
if (isWebAuthnUserAbortError(err)) {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
if (prfRequested) {
|
if (prfRequested) {
|
||||||
console.warn('Passkey authentication with PRF extension failed, retrying without PRF:', err)
|
console.warn('Passkey authentication with PRF extension failed, retrying without PRF:', err)
|
||||||
if (options.extensions) {
|
if (options.extensions) {
|
||||||
|
|||||||
@@ -64,6 +64,15 @@ export interface LocalGpsTrack {
|
|||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LocalNmeaArchive {
|
||||||
|
entryId: string
|
||||||
|
logbookId: string
|
||||||
|
encryptedData: string
|
||||||
|
iv: string
|
||||||
|
tag: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface LocalLogbookKey {
|
export interface LocalLogbookKey {
|
||||||
logbookId: string
|
logbookId: string
|
||||||
encryptedKey: string
|
encryptedKey: string
|
||||||
@@ -81,6 +90,15 @@ export interface SyncQueueItem {
|
|||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EntryDraftRecord {
|
||||||
|
logbookId: string
|
||||||
|
entryId: string
|
||||||
|
encryptedData: string
|
||||||
|
iv: string
|
||||||
|
tag: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
class DaagboxDatabase extends Dexie {
|
class DaagboxDatabase extends Dexie {
|
||||||
logbooks!: Table<LocalLogbook>
|
logbooks!: Table<LocalLogbook>
|
||||||
yachts!: Table<LocalYacht>
|
yachts!: Table<LocalYacht>
|
||||||
@@ -89,8 +107,10 @@ class DaagboxDatabase extends Dexie {
|
|||||||
entries!: Table<LocalEntry>
|
entries!: Table<LocalEntry>
|
||||||
photos!: Table<LocalPhoto>
|
photos!: Table<LocalPhoto>
|
||||||
gpsTracks!: Table<LocalGpsTrack>
|
gpsTracks!: Table<LocalGpsTrack>
|
||||||
|
nmeaArchives!: Table<LocalNmeaArchive>
|
||||||
logbookKeys!: Table<LocalLogbookKey>
|
logbookKeys!: Table<LocalLogbookKey>
|
||||||
syncQueue!: Table<SyncQueueItem>
|
syncQueue!: Table<SyncQueueItem>
|
||||||
|
entryDrafts!: Table<EntryDraftRecord, [string, string]>
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('DaagboxDatabase')
|
super('DaagboxDatabase')
|
||||||
@@ -145,6 +165,31 @@ class DaagboxDatabase extends Dexie {
|
|||||||
gpsTracks: 'entryId, logbookId, updatedAt',
|
gpsTracks: 'entryId, logbookId, updatedAt',
|
||||||
logbookKeys: 'logbookId'
|
logbookKeys: 'logbookId'
|
||||||
})
|
})
|
||||||
|
this.version(6).stores({
|
||||||
|
logbooks: 'id, encryptedTitle, updatedAt, isSynced, isShared, isDemo',
|
||||||
|
yachts: 'logbookId, updatedAt',
|
||||||
|
crews: 'payloadId, logbookId, updatedAt',
|
||||||
|
deviations: 'logbookId, updatedAt',
|
||||||
|
entries: 'payloadId, logbookId, updatedAt',
|
||||||
|
syncQueue: '++id, action, type, payloadId, logbookId',
|
||||||
|
photos: 'payloadId, entryId, logbookId, updatedAt',
|
||||||
|
gpsTracks: 'entryId, logbookId, updatedAt',
|
||||||
|
nmeaArchives: 'entryId, logbookId, updatedAt',
|
||||||
|
logbookKeys: 'logbookId'
|
||||||
|
})
|
||||||
|
this.version(7).stores({
|
||||||
|
logbooks: 'id, encryptedTitle, updatedAt, isSynced, isShared, isDemo',
|
||||||
|
yachts: 'logbookId, updatedAt',
|
||||||
|
crews: 'payloadId, logbookId, updatedAt',
|
||||||
|
deviations: 'logbookId, updatedAt',
|
||||||
|
entries: 'payloadId, logbookId, updatedAt',
|
||||||
|
syncQueue: '++id, action, type, payloadId, logbookId',
|
||||||
|
photos: 'payloadId, entryId, logbookId, updatedAt',
|
||||||
|
gpsTracks: 'entryId, logbookId, updatedAt',
|
||||||
|
nmeaArchives: 'entryId, logbookId, updatedAt',
|
||||||
|
logbookKeys: 'logbookId',
|
||||||
|
entryDrafts: '[logbookId+entryId], updatedAt'
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { db } from './db.js'
|
||||||
|
import { encryptJson, decryptJson } from './crypto.js'
|
||||||
|
import { getActiveMasterKey } from './auth.js'
|
||||||
|
|
||||||
|
export interface EntryDraftRecord {
|
||||||
|
logbookId: string
|
||||||
|
entryId: string
|
||||||
|
encryptedData: string
|
||||||
|
iv: string
|
||||||
|
tag: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveEntryDraft(
|
||||||
|
logbookId: string,
|
||||||
|
entryId: string,
|
||||||
|
payload: unknown
|
||||||
|
): Promise<void> {
|
||||||
|
const masterKey = getActiveMasterKey()
|
||||||
|
if (!masterKey) return
|
||||||
|
|
||||||
|
const { ciphertext, iv, tag } = await encryptJson(payload, masterKey)
|
||||||
|
await db.entryDrafts.put({
|
||||||
|
logbookId,
|
||||||
|
entryId,
|
||||||
|
encryptedData: ciphertext,
|
||||||
|
iv,
|
||||||
|
tag,
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadEntryDraft<T = unknown>(
|
||||||
|
logbookId: string,
|
||||||
|
entryId: string
|
||||||
|
): Promise<T | null> {
|
||||||
|
const masterKey = getActiveMasterKey()
|
||||||
|
if (!masterKey) return null
|
||||||
|
|
||||||
|
const row = await db.entryDrafts.get([logbookId, entryId])
|
||||||
|
if (!row) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
return (await decryptJson(row.encryptedData, row.iv, row.tag, masterKey)) as T
|
||||||
|
} catch {
|
||||||
|
await db.entryDrafts.delete([logbookId, entryId])
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearEntryDraft(logbookId: string, entryId: string): Promise<void> {
|
||||||
|
await db.entryDrafts.delete([logbookId, entryId])
|
||||||
|
}
|
||||||
@@ -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) {
|
if (response.ok) {
|
||||||
const serverLb = await response.json()
|
const serverLb = await response.json()
|
||||||
|
if (serverLb.id !== localId) {
|
||||||
|
await saveLogbookKey(serverLb.id, logbookKey)
|
||||||
|
await db.logbookKeys.delete(localId)
|
||||||
|
}
|
||||||
await db.logbooks.put({
|
await db.logbooks.put({
|
||||||
id: serverLb.id,
|
id: serverLb.id,
|
||||||
encryptedTitle: serverLb.encryptedTitle,
|
encryptedTitle: serverLb.encryptedTitle,
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { parseNmeaFile } from './nmeaParse.js'
|
||||||
|
import { detectNmeaChanges } from './nmeaChangeDetection.js'
|
||||||
|
import { generateNmeaJournalCandidates } from './nmeaJournalGenerator.js'
|
||||||
|
|
||||||
|
const nmeaPath = resolve(import.meta.dirname, '../../../../testdata/tracks/kieler-foerde-5sm.nmea')
|
||||||
|
|
||||||
|
describe('kieler-foerde testdata', () => {
|
||||||
|
it('parses the sample NMEA log and yields journal candidates', () => {
|
||||||
|
const text = readFileSync(nmeaPath, 'utf8')
|
||||||
|
const result = parseNmeaFile(text, 'kieler-foerde-5sm.nmea')
|
||||||
|
|
||||||
|
expect(result.stats.checksumErrors).toBe(0)
|
||||||
|
expect(result.points.length).toBeGreaterThan(30)
|
||||||
|
expect(result.stats.sentenceTypes).toEqual(expect.arrayContaining(['RMC', 'GGA', 'MWV', 'DPT', 'MDA']))
|
||||||
|
|
||||||
|
const changes = detectNmeaChanges(result.points)
|
||||||
|
expect(changes.length).toBeGreaterThan(0)
|
||||||
|
expect(changes.some((c) => ['wind', 'engine_start', 'departure', 'speed', 'depth'].includes(c.type))).toBe(true)
|
||||||
|
|
||||||
|
const journal = generateNmeaJournalCandidates({
|
||||||
|
points: result.points,
|
||||||
|
mode: 'both',
|
||||||
|
intervalMinutes: 60,
|
||||||
|
t: (key) => key
|
||||||
|
})
|
||||||
|
expect(journal.candidates.length).toBeGreaterThanOrEqual(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import type { NmeaTimePoint } from './nmeaTypes.js'
|
||||||
|
import { detectNmeaChanges } from './nmeaChangeDetection.js'
|
||||||
|
|
||||||
|
function point(
|
||||||
|
timestamp: number,
|
||||||
|
overrides: Partial<NmeaTimePoint> = {}
|
||||||
|
): NmeaTimePoint {
|
||||||
|
return { timestamp, ...overrides }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('detectNmeaChanges', () => {
|
||||||
|
it('detects significant course changes while underway', () => {
|
||||||
|
const points = [
|
||||||
|
point(0, { cog: 0, sog: 5 }),
|
||||||
|
point(60_000, { cog: 45, sog: 5 })
|
||||||
|
]
|
||||||
|
|
||||||
|
const events = detectNmeaChanges(points, {
|
||||||
|
courseDeltaDeg: 30,
|
||||||
|
windDirDeltaDeg: 30,
|
||||||
|
windSpeedDeltaKnots: 5,
|
||||||
|
pressureDeltaHpa: 2,
|
||||||
|
depthDeltaM: 1,
|
||||||
|
depthDeltaPercent: 25,
|
||||||
|
rpmIdle: 400,
|
||||||
|
rpmRunning: 800,
|
||||||
|
sogUnderWayKn: 2,
|
||||||
|
sogStoppedKn: 0.5,
|
||||||
|
anchorMinutes: 10,
|
||||||
|
speedDeltaKn: 2,
|
||||||
|
dedupeWindowMs: 60_000
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(events.some((e) => e.type === 'course')).toBe(true)
|
||||||
|
const course = events.find((e) => e.type === 'course')
|
||||||
|
expect(course?.summaryParams).toMatchObject({ from: 0, to: 45 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detects engine start when RPM rises above threshold', () => {
|
||||||
|
const points = [
|
||||||
|
point(0, { sog: 0, rpm: 0 }),
|
||||||
|
point(30_000, { sog: 3, rpm: 1200 })
|
||||||
|
]
|
||||||
|
|
||||||
|
const events = detectNmeaChanges(points)
|
||||||
|
expect(events.some((e) => e.type === 'engine_start')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('dedupes repeated events within the configured window', () => {
|
||||||
|
const points = [
|
||||||
|
point(0, { cog: 0, sog: 5 }),
|
||||||
|
point(10_000, { cog: 50, sog: 5 }),
|
||||||
|
point(20_000, { cog: 100, sog: 5 })
|
||||||
|
]
|
||||||
|
|
||||||
|
const events = detectNmeaChanges(points, {
|
||||||
|
courseDeltaDeg: 30,
|
||||||
|
windDirDeltaDeg: 30,
|
||||||
|
windSpeedDeltaKnots: 5,
|
||||||
|
pressureDeltaHpa: 2,
|
||||||
|
depthDeltaM: 1,
|
||||||
|
depthDeltaPercent: 25,
|
||||||
|
rpmIdle: 400,
|
||||||
|
rpmRunning: 800,
|
||||||
|
sogUnderWayKn: 2,
|
||||||
|
sogStoppedKn: 0.5,
|
||||||
|
anchorMinutes: 10,
|
||||||
|
speedDeltaKn: 2,
|
||||||
|
dedupeWindowMs: 120_000
|
||||||
|
})
|
||||||
|
|
||||||
|
const courseEvents = events.filter((e) => e.type === 'course')
|
||||||
|
expect(courseEvents.length).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import type { NmeaChangeEvent, NmeaDetectionConfig, NmeaTimePoint } from './nmeaTypes.js'
|
||||||
|
import { DEFAULT_NMEA_DETECTION_CONFIG } from './nmeaTypes.js'
|
||||||
|
import { angularDelta } from './nmeaTimeSeries.js'
|
||||||
|
|
||||||
|
function pushUnique(events: NmeaChangeEvent[], event: NmeaChangeEvent, minGapMs: number) {
|
||||||
|
const last = events[events.length - 1]
|
||||||
|
if (last && last.type === event.type && event.timestamp - last.timestamp < minGapMs) return
|
||||||
|
events.push(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectNmeaChanges(
|
||||||
|
points: NmeaTimePoint[],
|
||||||
|
config: NmeaDetectionConfig = DEFAULT_NMEA_DETECTION_CONFIG
|
||||||
|
): NmeaChangeEvent[] {
|
||||||
|
const events: NmeaChangeEvent[] = []
|
||||||
|
if (points.length < 2) return events
|
||||||
|
|
||||||
|
let lastCourse: number | undefined
|
||||||
|
let lastWindDir: number | undefined
|
||||||
|
let lastWindSpeed: number | undefined
|
||||||
|
let lastPressure: number | undefined
|
||||||
|
let lastDepth: number | undefined
|
||||||
|
let lastWaterTemp: number | undefined
|
||||||
|
let lastFix: boolean | undefined
|
||||||
|
let engineRunning = false
|
||||||
|
let autopilot: boolean | undefined
|
||||||
|
let underWay = false
|
||||||
|
let stoppedSince: number | null = null
|
||||||
|
let lastSog: number | undefined
|
||||||
|
|
||||||
|
for (const p of points) {
|
||||||
|
const course = p.cog ?? p.hdt ?? p.hdm
|
||||||
|
if (course != null && lastCourse != null && (p.sog ?? 0) > 1) {
|
||||||
|
if (angularDelta(course, lastCourse) >= config.courseDeltaDeg) {
|
||||||
|
pushUnique(events, {
|
||||||
|
type: 'course',
|
||||||
|
timestamp: p.timestamp,
|
||||||
|
confidence: 'high',
|
||||||
|
summaryKey: 'logs.nmea_change_course',
|
||||||
|
summaryParams: { from: Math.round(lastCourse), to: Math.round(course) },
|
||||||
|
data: p
|
||||||
|
}, config.dedupeWindowMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (course != null) lastCourse = course
|
||||||
|
|
||||||
|
if (p.windDir != null && lastWindDir != null) {
|
||||||
|
if (angularDelta(p.windDir, lastWindDir) >= config.windDirDeltaDeg) {
|
||||||
|
pushUnique(events, {
|
||||||
|
type: 'wind',
|
||||||
|
timestamp: p.timestamp,
|
||||||
|
confidence: 'high',
|
||||||
|
summaryKey: 'logs.nmea_change_wind',
|
||||||
|
summaryParams: { from: Math.round(lastWindDir), to: Math.round(p.windDir) },
|
||||||
|
data: p
|
||||||
|
}, config.dedupeWindowMs)
|
||||||
|
} else if (
|
||||||
|
p.windSpeedKnots != null &&
|
||||||
|
lastWindSpeed != null &&
|
||||||
|
Math.abs(p.windSpeedKnots - lastWindSpeed) >= config.windSpeedDeltaKnots
|
||||||
|
) {
|
||||||
|
pushUnique(events, {
|
||||||
|
type: 'wind',
|
||||||
|
timestamp: p.timestamp,
|
||||||
|
confidence: 'medium',
|
||||||
|
summaryKey: 'logs.nmea_change_wind_speed',
|
||||||
|
summaryParams: { from: lastWindSpeed.toFixed(1), to: p.windSpeedKnots.toFixed(1) },
|
||||||
|
data: p
|
||||||
|
}, config.dedupeWindowMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (p.windDir != null) lastWindDir = p.windDir
|
||||||
|
if (p.windSpeedKnots != null) lastWindSpeed = p.windSpeedKnots
|
||||||
|
|
||||||
|
if (p.pressureHpa != null && lastPressure != null) {
|
||||||
|
if (Math.abs(p.pressureHpa - lastPressure) >= config.pressureDeltaHpa) {
|
||||||
|
pushUnique(events, {
|
||||||
|
type: 'pressure',
|
||||||
|
timestamp: p.timestamp,
|
||||||
|
confidence: 'medium',
|
||||||
|
summaryKey: 'logs.nmea_change_pressure',
|
||||||
|
summaryParams: { from: lastPressure.toFixed(1), to: p.pressureHpa.toFixed(1) },
|
||||||
|
data: p
|
||||||
|
}, config.dedupeWindowMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (p.pressureHpa != null) lastPressure = p.pressureHpa
|
||||||
|
|
||||||
|
if (p.depthM != null && lastDepth != null) {
|
||||||
|
const delta = Math.abs(p.depthM - lastDepth)
|
||||||
|
const rel = lastDepth > 0 ? (delta / lastDepth) * 100 : 100
|
||||||
|
if (delta >= config.depthDeltaM || rel >= config.depthDeltaPercent) {
|
||||||
|
pushUnique(events, {
|
||||||
|
type: 'depth',
|
||||||
|
timestamp: p.timestamp,
|
||||||
|
confidence: 'high',
|
||||||
|
summaryKey: 'logs.nmea_change_depth',
|
||||||
|
summaryParams: { from: lastDepth.toFixed(1), to: p.depthM.toFixed(1) },
|
||||||
|
data: p
|
||||||
|
}, config.dedupeWindowMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (p.depthM != null) lastDepth = p.depthM
|
||||||
|
|
||||||
|
if (p.rpm != null) {
|
||||||
|
const running = p.rpm >= config.rpmRunning
|
||||||
|
const idle = p.rpm <= config.rpmIdle
|
||||||
|
if (running && !engineRunning) {
|
||||||
|
pushUnique(events, {
|
||||||
|
type: 'engine_start',
|
||||||
|
timestamp: p.timestamp,
|
||||||
|
confidence: 'high',
|
||||||
|
summaryKey: 'logs.nmea_change_engine_start',
|
||||||
|
summaryParams: { rpm: Math.round(p.rpm) },
|
||||||
|
data: p
|
||||||
|
}, config.dedupeWindowMs)
|
||||||
|
engineRunning = true
|
||||||
|
} else if (idle && engineRunning) {
|
||||||
|
pushUnique(events, {
|
||||||
|
type: 'engine_stop',
|
||||||
|
timestamp: p.timestamp,
|
||||||
|
confidence: 'high',
|
||||||
|
summaryKey: 'logs.nmea_change_engine_stop',
|
||||||
|
data: p
|
||||||
|
}, config.dedupeWindowMs)
|
||||||
|
engineRunning = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p.autopilotEngaged != null && autopilot != null && p.autopilotEngaged !== autopilot) {
|
||||||
|
pushUnique(events, {
|
||||||
|
type: p.autopilotEngaged ? 'autopilot_on' : 'autopilot_off',
|
||||||
|
timestamp: p.timestamp,
|
||||||
|
confidence: 'high',
|
||||||
|
summaryKey: p.autopilotEngaged ? 'logs.nmea_change_autopilot_on' : 'logs.nmea_change_autopilot_off',
|
||||||
|
data: p
|
||||||
|
}, config.dedupeWindowMs)
|
||||||
|
}
|
||||||
|
if (p.autopilotEngaged != null) autopilot = p.autopilotEngaged
|
||||||
|
|
||||||
|
if (p.fixValid != null && lastFix != null && p.fixValid !== lastFix) {
|
||||||
|
pushUnique(events, {
|
||||||
|
type: p.fixValid ? 'gps_fix_regained' : 'gps_fix_lost',
|
||||||
|
timestamp: p.timestamp,
|
||||||
|
confidence: 'high',
|
||||||
|
summaryKey: p.fixValid ? 'logs.nmea_change_gps_regained' : 'logs.nmea_change_gps_lost',
|
||||||
|
data: p
|
||||||
|
}, config.dedupeWindowMs)
|
||||||
|
}
|
||||||
|
if (p.fixValid != null) lastFix = p.fixValid
|
||||||
|
|
||||||
|
if (p.waterTempC != null && lastWaterTemp != null) {
|
||||||
|
if (Math.abs(p.waterTempC - lastWaterTemp) >= 2) {
|
||||||
|
pushUnique(events, {
|
||||||
|
type: 'water_temp',
|
||||||
|
timestamp: p.timestamp,
|
||||||
|
confidence: 'medium',
|
||||||
|
summaryKey: 'logs.nmea_change_water_temp',
|
||||||
|
summaryParams: { from: lastWaterTemp.toFixed(1), to: p.waterTempC.toFixed(1) },
|
||||||
|
data: p
|
||||||
|
}, config.dedupeWindowMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (p.waterTempC != null) lastWaterTemp = p.waterTempC
|
||||||
|
|
||||||
|
const sog = p.sog ?? 0
|
||||||
|
if (sog >= config.sogUnderWayKn && !underWay) {
|
||||||
|
if (stoppedSince != null && p.timestamp - stoppedSince >= config.anchorMinutes * 60 * 1000) {
|
||||||
|
pushUnique(events, {
|
||||||
|
type: 'departure',
|
||||||
|
timestamp: p.timestamp,
|
||||||
|
confidence: 'medium',
|
||||||
|
summaryKey: 'logs.nmea_change_departure',
|
||||||
|
data: p
|
||||||
|
}, config.dedupeWindowMs)
|
||||||
|
}
|
||||||
|
underWay = true
|
||||||
|
stoppedSince = null
|
||||||
|
}
|
||||||
|
if (sog <= config.sogStoppedKn && underWay) {
|
||||||
|
underWay = false
|
||||||
|
stoppedSince = p.timestamp
|
||||||
|
}
|
||||||
|
if (sog <= config.sogStoppedKn && stoppedSince != null && !underWay) {
|
||||||
|
if (p.timestamp - stoppedSince >= config.anchorMinutes * 60 * 1000) {
|
||||||
|
pushUnique(events, {
|
||||||
|
type: 'anchor',
|
||||||
|
timestamp: p.timestamp,
|
||||||
|
confidence: 'medium',
|
||||||
|
summaryKey: 'logs.nmea_change_anchor',
|
||||||
|
data: p
|
||||||
|
}, config.dedupeWindowMs)
|
||||||
|
stoppedSince = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastSog != null && Math.abs(sog - lastSog) >= config.speedDeltaKn) {
|
||||||
|
pushUnique(events, {
|
||||||
|
type: 'speed',
|
||||||
|
timestamp: p.timestamp,
|
||||||
|
confidence: 'low',
|
||||||
|
summaryKey: 'logs.nmea_change_speed',
|
||||||
|
summaryParams: { from: lastSog.toFixed(1), to: sog.toFixed(1) },
|
||||||
|
data: p
|
||||||
|
}, config.dedupeWindowMs)
|
||||||
|
}
|
||||||
|
lastSog = sog
|
||||||
|
}
|
||||||
|
|
||||||
|
return events.sort((a, b) => a.timestamp - b.timestamp)
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import type { TFunction } from 'i18next'
|
||||||
|
import type { LogEventPayload } from '../../utils/logEntryPayload.js'
|
||||||
|
import { normalizeLogEvent } from '../../utils/logEntryPayload.js'
|
||||||
|
import { formatCourseAngle } from '../../utils/courseAngle.js'
|
||||||
|
import { degreesToCardinal } from '../../utils/courseAngle.js'
|
||||||
|
import type {
|
||||||
|
NmeaChangeEvent,
|
||||||
|
NmeaImportMode,
|
||||||
|
NmeaJournalCandidate,
|
||||||
|
NmeaTimePoint
|
||||||
|
} from './nmeaTypes.js'
|
||||||
|
import { detectNmeaChanges } from './nmeaChangeDetection.js'
|
||||||
|
import { intervalTimestamps, sampleAt, timestampToHHMM } from './nmeaTimeSeries.js'
|
||||||
|
|
||||||
|
export interface GeneratedNmeaJournal {
|
||||||
|
candidates: Array<NmeaJournalCandidate & { event: LogEventPayload }>
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointToLogEvent(
|
||||||
|
point: NmeaTimePoint,
|
||||||
|
remarks: string,
|
||||||
|
sailsOrMotor: string
|
||||||
|
): LogEventPayload {
|
||||||
|
const course = point.cog ?? point.hdt ?? point.hdm
|
||||||
|
const mgk = course != null ? formatCourseAngle(course) : ''
|
||||||
|
const windDir =
|
||||||
|
point.windDir != null ? degreesToCardinal(point.windDir) : ''
|
||||||
|
|
||||||
|
return normalizeLogEvent({
|
||||||
|
time: timestampToHHMM(point.timestamp),
|
||||||
|
mgk,
|
||||||
|
rwk: '',
|
||||||
|
windDirection: windDir,
|
||||||
|
windStrength: point.windSpeedKnots != null ? String(point.windSpeedKnots) : '',
|
||||||
|
windPressure: point.pressureHpa != null ? String(Math.round(point.pressureHpa)) : '',
|
||||||
|
gpsLat: point.lat != null ? point.lat.toFixed(6) : '',
|
||||||
|
gpsLng: point.lng != null ? point.lng.toFixed(6) : '',
|
||||||
|
logReading: point.logDistanceNm != null ? point.logDistanceNm.toFixed(2) : '',
|
||||||
|
sailsOrMotor,
|
||||||
|
remarks
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeToSailsOrMotor(type: NmeaChangeEvent['type']): string {
|
||||||
|
if (type === 'engine_start') return 'Motor'
|
||||||
|
if (type === 'engine_stop') return 'Segel'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRemarks(change: NmeaChangeEvent, t: TFunction): string {
|
||||||
|
const parts: string[] = []
|
||||||
|
parts.push(t(change.summaryKey, change.summaryParams ?? {}))
|
||||||
|
if (change.data?.depthM != null) {
|
||||||
|
parts.push(t('logs.nmea_remark_depth', { depth: change.data.depthM.toFixed(1) }))
|
||||||
|
}
|
||||||
|
if (change.confidence === 'low') {
|
||||||
|
parts.push(t('logs.nmea_remark_uncertain'))
|
||||||
|
}
|
||||||
|
return parts.join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeCandidates(
|
||||||
|
items: Array<NmeaJournalCandidate & { event: LogEventPayload; timestamp: number }>,
|
||||||
|
windowMs: number
|
||||||
|
): Array<NmeaJournalCandidate & { event: LogEventPayload }> {
|
||||||
|
const sorted = [...items].sort((a, b) => a.timestamp - b.timestamp)
|
||||||
|
const kept: typeof sorted = []
|
||||||
|
|
||||||
|
for (const item of sorted) {
|
||||||
|
const near = kept.find((k) => Math.abs(k.timestamp - item.timestamp) <= windowMs)
|
||||||
|
if (!near) {
|
||||||
|
kept.push(item)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (item.source === 'change' && near.source === 'interval') {
|
||||||
|
const idx = kept.indexOf(near)
|
||||||
|
kept[idx] = {
|
||||||
|
...item,
|
||||||
|
event: {
|
||||||
|
...near.event,
|
||||||
|
remarks: [item.event.remarks, near.event.remarks].filter(Boolean).join(' · ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return kept
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateNmeaJournalCandidates(options: {
|
||||||
|
points: NmeaTimePoint[]
|
||||||
|
mode: NmeaImportMode
|
||||||
|
intervalMinutes: number
|
||||||
|
t: TFunction
|
||||||
|
}): GeneratedNmeaJournal {
|
||||||
|
const { points, mode, intervalMinutes, t } = options
|
||||||
|
const items: Array<NmeaJournalCandidate & { event: LogEventPayload; timestamp: number }> = []
|
||||||
|
|
||||||
|
if (mode === 'interval' || mode === 'both') {
|
||||||
|
for (const ts of intervalTimestamps(points, intervalMinutes)) {
|
||||||
|
const sample = sampleAt(points, ts)
|
||||||
|
if (!sample) continue
|
||||||
|
items.push({
|
||||||
|
id: `interval-${ts}`,
|
||||||
|
timestamp: ts,
|
||||||
|
source: 'interval',
|
||||||
|
selected: true,
|
||||||
|
event: pointToLogEvent(sample, t('logs.nmea_remark_interval'), '')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'change' || mode === 'both') {
|
||||||
|
const changes = detectNmeaChanges(points)
|
||||||
|
for (const change of changes) {
|
||||||
|
const sample = change.data ?? sampleAt(points, change.timestamp)
|
||||||
|
if (!sample) continue
|
||||||
|
items.push({
|
||||||
|
id: `change-${change.type}-${change.timestamp}`,
|
||||||
|
timestamp: change.timestamp,
|
||||||
|
source: 'change',
|
||||||
|
changeType: change.type,
|
||||||
|
confidence: change.confidence,
|
||||||
|
selected: true,
|
||||||
|
event: pointToLogEvent(
|
||||||
|
{ ...sample, timestamp: change.timestamp },
|
||||||
|
buildRemarks(change, t),
|
||||||
|
changeToSailsOrMotor(change.type)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deduped = mode === 'both'
|
||||||
|
? dedupeCandidates(items, 15 * 60 * 1000)
|
||||||
|
: items
|
||||||
|
|
||||||
|
return { candidates: deduped }
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { nmeaPointsToWaypoints, parseNmeaFile } from './nmeaParse.js'
|
||||||
|
|
||||||
|
describe('parseNmeaFile', () => {
|
||||||
|
it('parses RMC position, course and speed', () => {
|
||||||
|
const text = [
|
||||||
|
'$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W',
|
||||||
|
'$GPRMC,133519,A,4808.038,N,01132.000,E,025.0,090.0,230394,003.1,W'
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
const result = parseNmeaFile(text, 'test.nmea')
|
||||||
|
|
||||||
|
expect(result.stats.parsedLines).toBe(2)
|
||||||
|
expect(result.stats.sentenceTypes).toContain('RMC')
|
||||||
|
expect(result.points.length).toBeGreaterThanOrEqual(2)
|
||||||
|
|
||||||
|
const first = result.points[0]
|
||||||
|
expect(first.lat).toBeCloseTo(48.1173, 3)
|
||||||
|
expect(first.lng).toBeCloseTo(11.516667, 3)
|
||||||
|
expect(first.sog).toBe(22.4)
|
||||||
|
expect(first.cog).toBe(84.4)
|
||||||
|
expect(first.fixValid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('merges wind and depth sentences onto the same timestamp', () => {
|
||||||
|
const text = [
|
||||||
|
'$GPRMC,100000,A,5400.000,N,01000.000,E,5.0,180.0,010124,003.0,E',
|
||||||
|
'$IIMWV,270.0,R,12.5,N,A',
|
||||||
|
'$SDDPT,4.5,0.0'
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
const result = parseNmeaFile(text, 'merged.nmea')
|
||||||
|
const last = result.points[result.points.length - 1]
|
||||||
|
|
||||||
|
expect(last.windDir).toBe(270)
|
||||||
|
expect(last.windSpeedKnots).toBe(12.5)
|
||||||
|
expect(last.depthM).toBe(4.5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips lines with invalid checksum', () => {
|
||||||
|
const text = '$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*FF'
|
||||||
|
const result = parseNmeaFile(text, 'bad.nmea')
|
||||||
|
|
||||||
|
expect(result.stats.checksumErrors).toBe(1)
|
||||||
|
expect(result.points).toHaveLength(0)
|
||||||
|
expect(result.warnings).toContain('no_samples')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('warns when no position sentences are present', () => {
|
||||||
|
const text = '$IIMWV,090.0,R,8.0,N,A'
|
||||||
|
const result = parseNmeaFile(text, 'wind-only.nmea')
|
||||||
|
|
||||||
|
expect(result.warnings).toContain('no_position')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('nmeaPointsToWaypoints', () => {
|
||||||
|
it('maps points with coordinates to track waypoints', () => {
|
||||||
|
const waypoints = nmeaPointsToWaypoints([
|
||||||
|
{ timestamp: 1, lat: 54.0, lng: 10.0, sog: 6, cog: 90 },
|
||||||
|
{ timestamp: 2, windDir: 180 },
|
||||||
|
{ timestamp: 3, lat: 54.01, lng: 10.01, hdt: 95 }
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(waypoints).toHaveLength(2)
|
||||||
|
expect(waypoints[0]).toMatchObject({ lat: 54, lng: 10, speedKnots: 6, heading: 90 })
|
||||||
|
expect(waypoints[1].heading).toBe(95)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import type { NmeaParseResult, NmeaParseStats, NmeaTimePoint } from './nmeaTypes.js'
|
||||||
|
|
||||||
|
function parseChecksum(line: string): boolean {
|
||||||
|
const star = line.lastIndexOf('*')
|
||||||
|
if (star < 0) return true
|
||||||
|
const expected = line.slice(star + 1, star + 3)
|
||||||
|
if (!/^[0-9A-Fa-f]{2}$/.test(expected)) return false
|
||||||
|
let sum = 0
|
||||||
|
for (let i = 1; i < star; i++) sum ^= line.charCodeAt(i)
|
||||||
|
return sum.toString(16).toUpperCase().padStart(2, '0') === expected.toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function sentenceType(field0: string): string {
|
||||||
|
return field0.length >= 3 ? field0.slice(-3) : field0
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLatLon(latStr: string, latHem: string, lonStr: string, lonHem: string): { lat?: number; lng?: number } {
|
||||||
|
const latVal = parseFloat(latStr)
|
||||||
|
const lonVal = parseFloat(lonStr)
|
||||||
|
if (Number.isNaN(latVal) || Number.isNaN(lonVal)) return {}
|
||||||
|
const latDeg = Math.floor(latVal / 100)
|
||||||
|
const latMin = latVal - latDeg * 100
|
||||||
|
let lat = latDeg + latMin / 60
|
||||||
|
if (latHem === 'S') lat = -lat
|
||||||
|
|
||||||
|
const lonDeg = Math.floor(lonVal / 100)
|
||||||
|
const lonMin = lonVal - lonDeg * 100
|
||||||
|
let lng = lonDeg + lonMin / 60
|
||||||
|
if (lonHem === 'W') lng = -lng
|
||||||
|
|
||||||
|
return { lat: Number(lat.toFixed(6)), lng: Number(lng.toFixed(6)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRmcDateTime(timeStr: string, dateStr: string, baseYear = new Date().getFullYear()): number | null {
|
||||||
|
if (!timeStr || timeStr.length < 6) return null
|
||||||
|
const hh = parseInt(timeStr.slice(0, 2), 10)
|
||||||
|
const mm = parseInt(timeStr.slice(2, 4), 10)
|
||||||
|
const ss = parseInt(timeStr.slice(4, 6), 10)
|
||||||
|
if ([hh, mm, ss].some((n) => Number.isNaN(n))) return null
|
||||||
|
|
||||||
|
let year = baseYear
|
||||||
|
let month = 0
|
||||||
|
let day = 1
|
||||||
|
if (dateStr && dateStr.length >= 6) {
|
||||||
|
day = parseInt(dateStr.slice(0, 2), 10)
|
||||||
|
month = parseInt(dateStr.slice(2, 4), 10) - 1
|
||||||
|
const yy = parseInt(dateStr.slice(4, 6), 10)
|
||||||
|
year = yy >= 70 ? 1900 + yy : 2000 + yy
|
||||||
|
}
|
||||||
|
|
||||||
|
return Date.UTC(year, month, day, hh, mm, ss)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWindSpeed(value: string, unit: string): number | undefined {
|
||||||
|
const speed = parseFloat(value)
|
||||||
|
if (Number.isNaN(speed)) return undefined
|
||||||
|
if (unit === 'N') return speed
|
||||||
|
if (unit === 'M') return speed * 1.94384
|
||||||
|
if (unit === 'K') return speed * 0.539957
|
||||||
|
return speed
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MutableState extends NmeaTimePoint {
|
||||||
|
lastTimestamp: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshot(state: MutableState): NmeaTimePoint | null {
|
||||||
|
if (state.lastTimestamp == null) return null
|
||||||
|
const { lastTimestamp, ...rest } = state
|
||||||
|
void lastTimestamp
|
||||||
|
if (
|
||||||
|
rest.lat == null &&
|
||||||
|
rest.lng == null &&
|
||||||
|
rest.cog == null &&
|
||||||
|
rest.sog == null &&
|
||||||
|
rest.hdt == null &&
|
||||||
|
rest.windDir == null &&
|
||||||
|
rest.windSpeedKnots == null &&
|
||||||
|
rest.depthM == null &&
|
||||||
|
rest.rpm == null
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return rest as NmeaTimePoint
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushPoint(points: NmeaTimePoint[], state: MutableState) {
|
||||||
|
const snap = snapshot(state)
|
||||||
|
if (!snap) return
|
||||||
|
const last = points[points.length - 1]
|
||||||
|
if (last && last.timestamp === snap.timestamp) {
|
||||||
|
points[points.length - 1] = { ...last, ...snap }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
points.push(snap)
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySentence(state: MutableState, type: string, fields: string[], points: NmeaTimePoint[]) {
|
||||||
|
switch (type) {
|
||||||
|
case 'RMC': {
|
||||||
|
const status = fields[2]
|
||||||
|
const ts = parseRmcDateTime(fields[1], fields[9])
|
||||||
|
if (ts != null) {
|
||||||
|
state.timestamp = ts
|
||||||
|
state.lastTimestamp = ts
|
||||||
|
}
|
||||||
|
if (status === 'A') {
|
||||||
|
Object.assign(state, parseLatLon(fields[3], fields[4], fields[5], fields[6]))
|
||||||
|
state.fixValid = true
|
||||||
|
const sog = parseFloat(fields[7])
|
||||||
|
const cog = parseFloat(fields[8])
|
||||||
|
if (!Number.isNaN(sog)) state.sog = sog
|
||||||
|
if (!Number.isNaN(cog)) state.cog = cog
|
||||||
|
} else {
|
||||||
|
state.fixValid = false
|
||||||
|
}
|
||||||
|
pushPoint(points, state)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'GGA': {
|
||||||
|
const ts = parseRmcDateTime(fields[1], '')
|
||||||
|
if (ts != null) {
|
||||||
|
state.timestamp = ts
|
||||||
|
state.lastTimestamp = ts
|
||||||
|
}
|
||||||
|
Object.assign(state, parseLatLon(fields[2], fields[3], fields[4], fields[5]))
|
||||||
|
const quality = parseInt(fields[6], 10)
|
||||||
|
state.fixValid = !Number.isNaN(quality) && quality > 0
|
||||||
|
pushPoint(points, state)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'GLL': {
|
||||||
|
const ts = parseRmcDateTime(fields[5], fields[6] ?? '')
|
||||||
|
if (ts != null) {
|
||||||
|
state.timestamp = ts
|
||||||
|
state.lastTimestamp = ts
|
||||||
|
}
|
||||||
|
Object.assign(state, parseLatLon(fields[1], fields[2], fields[3], fields[4]))
|
||||||
|
state.fixValid = fields[7] === 'A'
|
||||||
|
pushPoint(points, state)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'VTG': {
|
||||||
|
const cog = parseFloat(fields[1])
|
||||||
|
const sog = parseFloat(fields[5] || fields[7])
|
||||||
|
if (!Number.isNaN(cog)) state.cog = cog
|
||||||
|
if (!Number.isNaN(sog)) state.sog = sog
|
||||||
|
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'HDT':
|
||||||
|
state.hdt = parseFloat(fields[1])
|
||||||
|
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||||
|
break
|
||||||
|
case 'HDM':
|
||||||
|
state.hdm = parseFloat(fields[1])
|
||||||
|
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||||
|
break
|
||||||
|
case 'HDG': {
|
||||||
|
const hdg = parseFloat(fields[1])
|
||||||
|
if (!Number.isNaN(hdg)) state.hdm = hdg
|
||||||
|
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'MWV': {
|
||||||
|
if (fields[5] !== 'A') break
|
||||||
|
const dir = parseFloat(fields[1])
|
||||||
|
const speed = parseWindSpeed(fields[3], fields[4])
|
||||||
|
if (!Number.isNaN(dir)) state.windDir = dir
|
||||||
|
if (speed != null) state.windSpeedKnots = Number(speed.toFixed(1))
|
||||||
|
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'MWD': {
|
||||||
|
const dir = parseFloat(fields[1])
|
||||||
|
const speed = parseFloat(fields[5])
|
||||||
|
if (!Number.isNaN(dir)) state.windDir = dir
|
||||||
|
if (!Number.isNaN(speed)) state.windSpeedKnots = Number(speed.toFixed(1))
|
||||||
|
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'DPT':
|
||||||
|
case 'DBT': {
|
||||||
|
const depth = parseFloat(fields[1])
|
||||||
|
if (!Number.isNaN(depth)) state.depthM = depth
|
||||||
|
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'RPM': {
|
||||||
|
const rpm = parseFloat(fields[3] ?? fields[2])
|
||||||
|
if (!Number.isNaN(rpm)) state.rpm = rpm
|
||||||
|
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'MDA': {
|
||||||
|
const inchHg = parseFloat(fields[3])
|
||||||
|
const hpaField = parseFloat(fields[15] ?? fields[4])
|
||||||
|
if (!Number.isNaN(hpaField) && hpaField > 800) state.pressureHpa = hpaField
|
||||||
|
else if (!Number.isNaN(inchHg)) state.pressureHpa = inchHg * 33.8639
|
||||||
|
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'MTW': {
|
||||||
|
const temp = parseFloat(fields[1])
|
||||||
|
if (!Number.isNaN(temp)) state.waterTempC = temp
|
||||||
|
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'VLW': {
|
||||||
|
const nm = parseFloat(fields[1] ?? fields[2])
|
||||||
|
if (!Number.isNaN(nm)) state.logDistanceNm = nm
|
||||||
|
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'APA': {
|
||||||
|
const mode = fields[1]
|
||||||
|
state.autopilotEngaged = mode === '1' || mode?.toUpperCase() === 'A'
|
||||||
|
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseNmeaFile(text: string, filename: string): NmeaParseResult {
|
||||||
|
const warnings: string[] = []
|
||||||
|
const points: NmeaTimePoint[] = []
|
||||||
|
const typesSeen = new Set<string>()
|
||||||
|
let totalLines = 0
|
||||||
|
let parsedLines = 0
|
||||||
|
let checksumErrors = 0
|
||||||
|
|
||||||
|
const state: MutableState = { timestamp: 0, lastTimestamp: null }
|
||||||
|
|
||||||
|
for (const rawLine of text.split(/\r?\n/)) {
|
||||||
|
const line = rawLine.trim()
|
||||||
|
if (!line || (!line.startsWith('$') && !line.startsWith('!'))) continue
|
||||||
|
totalLines++
|
||||||
|
if (!parseChecksum(line)) {
|
||||||
|
checksumErrors++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const star = line.indexOf('*')
|
||||||
|
const body = star >= 0 ? line.slice(0, star) : line
|
||||||
|
const fields = body.slice(1).split(',')
|
||||||
|
if (fields.length < 2) continue
|
||||||
|
|
||||||
|
const type = sentenceType(fields[0])
|
||||||
|
typesSeen.add(type)
|
||||||
|
applySentence(state, type, fields, points)
|
||||||
|
parsedLines++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (points.length === 0) {
|
||||||
|
warnings.push('no_samples')
|
||||||
|
}
|
||||||
|
if (!typesSeen.has('RMC') && !typesSeen.has('GGA') && !typesSeen.has('GLL')) {
|
||||||
|
warnings.push('no_position')
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats: NmeaParseStats = {
|
||||||
|
totalLines,
|
||||||
|
parsedLines,
|
||||||
|
checksumErrors,
|
||||||
|
sentenceTypes: [...typesSeen].sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
return { points, stats, warnings, rawText: text, filename }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nmeaPointsToWaypoints(points: NmeaTimePoint[]): import('../trackUpload.js').TrackWaypoint[] {
|
||||||
|
return points
|
||||||
|
.filter((p) => p.lat != null && p.lng != null)
|
||||||
|
.map((p) => ({
|
||||||
|
timestamp: p.timestamp,
|
||||||
|
lat: p.lat!,
|
||||||
|
lng: p.lng!,
|
||||||
|
speedKnots: p.sog,
|
||||||
|
heading: p.cog ?? p.hdt ?? p.hdm
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { NmeaTimePoint } from './nmeaTypes.js'
|
||||||
|
|
||||||
|
/** Nearest sample at or before timestamp (carry-forward). */
|
||||||
|
export function sampleAt(points: NmeaTimePoint[], timestamp: number): NmeaTimePoint | null {
|
||||||
|
if (points.length === 0) return null
|
||||||
|
let best: NmeaTimePoint | null = null
|
||||||
|
for (const p of points) {
|
||||||
|
if (p.timestamp <= timestamp) best = p
|
||||||
|
else break
|
||||||
|
}
|
||||||
|
return best ?? points[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterPointsForDate(points: NmeaTimePoint[], dateYmd: string): NmeaTimePoint[] {
|
||||||
|
if (!dateYmd || points.length === 0) return points
|
||||||
|
const [y, m, d] = dateYmd.split('-').map((v) => parseInt(v, 10))
|
||||||
|
if ([y, m, d].some((n) => Number.isNaN(n))) return points
|
||||||
|
|
||||||
|
const start = Date.UTC(y, m - 1, d, 0, 0, 0)
|
||||||
|
const end = Date.UTC(y, m - 1, d, 23, 59, 59)
|
||||||
|
|
||||||
|
const filtered = points.filter((p) => p.timestamp >= start && p.timestamp <= end)
|
||||||
|
return filtered.length > 0 ? filtered : points
|
||||||
|
}
|
||||||
|
|
||||||
|
export function timestampToHHMM(timestamp: number, timeZone?: string): string {
|
||||||
|
const opts: Intl.DateTimeFormatOptions = {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
timeZone: timeZone ?? undefined
|
||||||
|
}
|
||||||
|
const parts = new Intl.DateTimeFormat('en-GB', opts).formatToParts(new Date(timestamp))
|
||||||
|
const hh = parts.find((p) => p.type === 'hour')?.value ?? '00'
|
||||||
|
const mm = parts.find((p) => p.type === 'minute')?.value ?? '00'
|
||||||
|
return `${hh}:${mm}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function angularDelta(a: number, b: number): number {
|
||||||
|
const diff = Math.abs(a - b) % 360
|
||||||
|
return diff > 180 ? 360 - diff : diff
|
||||||
|
}
|
||||||
|
|
||||||
|
export function intervalTimestamps(
|
||||||
|
points: NmeaTimePoint[],
|
||||||
|
intervalMinutes: number
|
||||||
|
): number[] {
|
||||||
|
if (points.length === 0) return []
|
||||||
|
const start = points[0].timestamp
|
||||||
|
const end = points[points.length - 1].timestamp
|
||||||
|
const stepMs = intervalMinutes * 60 * 1000
|
||||||
|
const stamps: number[] = []
|
||||||
|
for (let t = start; t <= end; t += stepMs) {
|
||||||
|
stamps.push(t)
|
||||||
|
}
|
||||||
|
if (stamps[stamps.length - 1] !== end) stamps.push(end)
|
||||||
|
return stamps
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
export type NmeaChangeType =
|
||||||
|
| 'course'
|
||||||
|
| 'wind'
|
||||||
|
| 'pressure'
|
||||||
|
| 'engine_start'
|
||||||
|
| 'engine_stop'
|
||||||
|
| 'autopilot_on'
|
||||||
|
| 'autopilot_off'
|
||||||
|
| 'depth'
|
||||||
|
| 'anchor'
|
||||||
|
| 'departure'
|
||||||
|
| 'speed'
|
||||||
|
| 'gps_fix_lost'
|
||||||
|
| 'gps_fix_regained'
|
||||||
|
| 'water_temp'
|
||||||
|
| 'wind_shift'
|
||||||
|
|
||||||
|
export interface NmeaParseStats {
|
||||||
|
totalLines: number
|
||||||
|
parsedLines: number
|
||||||
|
checksumErrors: number
|
||||||
|
sentenceTypes: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NmeaTimePoint {
|
||||||
|
timestamp: number
|
||||||
|
lat?: number
|
||||||
|
lng?: number
|
||||||
|
cog?: number
|
||||||
|
sog?: number
|
||||||
|
hdt?: number
|
||||||
|
hdm?: number
|
||||||
|
windDir?: number
|
||||||
|
windSpeedKnots?: number
|
||||||
|
depthM?: number
|
||||||
|
rpm?: number
|
||||||
|
pressureHpa?: number
|
||||||
|
waterTempC?: number
|
||||||
|
logDistanceNm?: number
|
||||||
|
fixValid?: boolean
|
||||||
|
autopilotEngaged?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NmeaChangeEvent {
|
||||||
|
type: NmeaChangeType
|
||||||
|
timestamp: number
|
||||||
|
confidence: 'high' | 'medium' | 'low'
|
||||||
|
summaryKey: string
|
||||||
|
summaryParams?: Record<string, string | number>
|
||||||
|
data?: Partial<NmeaTimePoint>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NmeaParseResult {
|
||||||
|
points: NmeaTimePoint[]
|
||||||
|
stats: NmeaParseStats
|
||||||
|
warnings: string[]
|
||||||
|
rawText: string
|
||||||
|
filename: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NmeaImportMode = 'interval' | 'change' | 'both'
|
||||||
|
|
||||||
|
export interface NmeaJournalCandidate {
|
||||||
|
id: string
|
||||||
|
timestamp: number
|
||||||
|
source: 'interval' | 'change'
|
||||||
|
changeType?: NmeaChangeType
|
||||||
|
confidence?: 'high' | 'medium' | 'low'
|
||||||
|
selected: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NmeaDetectionConfig {
|
||||||
|
courseDeltaDeg: number
|
||||||
|
windDirDeltaDeg: number
|
||||||
|
windSpeedDeltaKnots: number
|
||||||
|
pressureDeltaHpa: number
|
||||||
|
depthDeltaM: number
|
||||||
|
depthDeltaPercent: number
|
||||||
|
rpmIdle: number
|
||||||
|
rpmRunning: number
|
||||||
|
sogUnderWayKn: number
|
||||||
|
sogStoppedKn: number
|
||||||
|
anchorMinutes: number
|
||||||
|
speedDeltaKn: number
|
||||||
|
dedupeWindowMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_NMEA_DETECTION_CONFIG: NmeaDetectionConfig = {
|
||||||
|
courseDeltaDeg: 28,
|
||||||
|
windDirDeltaDeg: 35,
|
||||||
|
windSpeedDeltaKnots: 4,
|
||||||
|
pressureDeltaHpa: 2,
|
||||||
|
depthDeltaM: 2,
|
||||||
|
depthDeltaPercent: 25,
|
||||||
|
rpmIdle: 400,
|
||||||
|
rpmRunning: 800,
|
||||||
|
sogUnderWayKn: 2,
|
||||||
|
sogStoppedKn: 0.5,
|
||||||
|
anchorMinutes: 10,
|
||||||
|
speedDeltaKn: 3,
|
||||||
|
dedupeWindowMs: 5 * 60 * 1000
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { isNmeaCrcAlreadyImported, type NmeaArchiveRecord } from './nmeaArchive.js'
|
||||||
|
import { nmeaFileCrc32 } from '../utils/crc32.js'
|
||||||
|
|
||||||
|
describe('nmeaArchive CRC tracking', () => {
|
||||||
|
it('detects duplicate file content by CRC32', () => {
|
||||||
|
const text = '$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W\n'
|
||||||
|
const record: NmeaArchiveRecord = {
|
||||||
|
filename: 'a.nmea',
|
||||||
|
rawText: '',
|
||||||
|
importedAt: '2026-05-29T10:00:00.000Z',
|
||||||
|
importedFiles: [{
|
||||||
|
crc32: nmeaFileCrc32(text),
|
||||||
|
filename: 'a.nmea',
|
||||||
|
importedAt: '2026-05-29T10:00:00.000Z'
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(isNmeaCrcAlreadyImported(record, text)).toBe(true)
|
||||||
|
expect(isNmeaCrcAlreadyImported(record, text.replace('\n', '\r\n'))).toBe(true)
|
||||||
|
expect(isNmeaCrcAlreadyImported(record, '$GPRMC,999999,A\n')).toBe(false)
|
||||||
|
expect(isNmeaCrcAlreadyImported(null, text)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { db } from './db.js'
|
||||||
|
import { getActiveMasterKey } from './auth.js'
|
||||||
|
import { getLogbookKey } from './logbookKeys.js'
|
||||||
|
import { encryptJson, decryptJson } from './crypto.js'
|
||||||
|
import { nmeaFileCrc32 } from '../utils/crc32.js'
|
||||||
|
|
||||||
|
export interface NmeaImportedFile {
|
||||||
|
crc32: string
|
||||||
|
filename: string
|
||||||
|
importedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NmeaArchiveRecord {
|
||||||
|
filename: string
|
||||||
|
rawText: string
|
||||||
|
importedAt: string
|
||||||
|
importedFiles: NmeaImportedFile[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeArchiveRecord(raw: Partial<NmeaArchiveRecord>): NmeaArchiveRecord {
|
||||||
|
const importedFiles = [...(raw.importedFiles ?? [])]
|
||||||
|
if (importedFiles.length === 0 && raw.rawText) {
|
||||||
|
importedFiles.push({
|
||||||
|
crc32: nmeaFileCrc32(raw.rawText),
|
||||||
|
filename: raw.filename ?? '',
|
||||||
|
importedAt: raw.importedAt ?? ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
filename: raw.filename ?? '',
|
||||||
|
rawText: raw.rawText ?? '',
|
||||||
|
importedAt: raw.importedAt ?? '',
|
||||||
|
importedFiles
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function putNmeaArchiveRecord(
|
||||||
|
logbookId: string,
|
||||||
|
entryId: string,
|
||||||
|
payload: NmeaArchiveRecord
|
||||||
|
): Promise<void> {
|
||||||
|
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||||
|
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||||
|
|
||||||
|
const encrypted = await encryptJson(payload, masterKey)
|
||||||
|
await db.nmeaArchives.put({
|
||||||
|
entryId,
|
||||||
|
logbookId,
|
||||||
|
encryptedData: encrypted.ciphertext,
|
||||||
|
iv: encrypted.iv,
|
||||||
|
tag: encrypted.tag,
|
||||||
|
updatedAt: payload.importedAt || new Date().toISOString()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNmeaArchive(entryId: string): Promise<NmeaArchiveRecord | null> {
|
||||||
|
const record = await db.nmeaArchives.get(entryId)
|
||||||
|
if (!record) return null
|
||||||
|
|
||||||
|
const masterKey = await getLogbookKey(record.logbookId) || getActiveMasterKey()
|
||||||
|
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||||
|
|
||||||
|
try {
|
||||||
|
return normalizeArchiveRecord(
|
||||||
|
await decryptJson(record.encryptedData, record.iv, record.tag, masterKey) as Partial<NmeaArchiveRecord>
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNmeaCrcAlreadyImported(record: NmeaArchiveRecord | null, rawText: string): boolean {
|
||||||
|
if (!record) return false
|
||||||
|
const crc32 = nmeaFileCrc32(rawText)
|
||||||
|
return record.importedFiles.some((file) => file.crc32 === crc32)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remember imported file by CRC (even when raw log is discarded). */
|
||||||
|
export async function recordNmeaFileImport(
|
||||||
|
logbookId: string,
|
||||||
|
entryId: string,
|
||||||
|
filename: string,
|
||||||
|
rawText: string
|
||||||
|
): Promise<string> {
|
||||||
|
const crc32 = nmeaFileCrc32(rawText)
|
||||||
|
const existing = await getNmeaArchive(entryId)
|
||||||
|
const importedFiles = [...(existing?.importedFiles ?? [])]
|
||||||
|
if (!importedFiles.some((file) => file.crc32 === crc32)) {
|
||||||
|
importedFiles.push({
|
||||||
|
crc32,
|
||||||
|
filename,
|
||||||
|
importedAt: new Date().toISOString()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: NmeaArchiveRecord = {
|
||||||
|
filename: existing?.filename ?? '',
|
||||||
|
rawText: existing?.rawText ?? '',
|
||||||
|
importedAt: new Date().toISOString(),
|
||||||
|
importedFiles
|
||||||
|
}
|
||||||
|
await putNmeaArchiveRecord(logbookId, entryId, payload)
|
||||||
|
return crc32
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveNmeaArchive(
|
||||||
|
logbookId: string,
|
||||||
|
entryId: string,
|
||||||
|
filename: string,
|
||||||
|
rawText: string
|
||||||
|
): Promise<void> {
|
||||||
|
const crc32 = nmeaFileCrc32(rawText)
|
||||||
|
const existing = await getNmeaArchive(entryId)
|
||||||
|
const importedFiles = [...(existing?.importedFiles ?? [])]
|
||||||
|
if (!importedFiles.some((file) => file.crc32 === crc32)) {
|
||||||
|
importedFiles.push({
|
||||||
|
crc32,
|
||||||
|
filename,
|
||||||
|
importedAt: new Date().toISOString()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: NmeaArchiveRecord = {
|
||||||
|
filename,
|
||||||
|
rawText,
|
||||||
|
importedAt: new Date().toISOString(),
|
||||||
|
importedFiles
|
||||||
|
}
|
||||||
|
await putNmeaArchiveRecord(logbookId, entryId, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteNmeaArchive(entryId: string): Promise<void> {
|
||||||
|
await db.nmeaArchives.delete(entryId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadNmeaArchive(record: NmeaArchiveRecord): void {
|
||||||
|
const blob = new Blob([record.rawText], { type: 'text/plain;charset=utf-8' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = record.filename || 'track.nmea'
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -2,6 +2,11 @@ import { db, type SyncQueueItem } from './db.js'
|
|||||||
import { getActiveMasterKey } from './auth.js'
|
import { getActiveMasterKey } from './auth.js'
|
||||||
import { apiFetch } from './api.js'
|
import { apiFetch } from './api.js'
|
||||||
import { getLogbookAccess } from './logbookAccess.js'
|
import { getLogbookAccess } from './logbookAccess.js'
|
||||||
|
import {
|
||||||
|
clearSyncConflict,
|
||||||
|
reportSyncConflict,
|
||||||
|
type SyncConflict
|
||||||
|
} from './syncConflicts.js'
|
||||||
|
|
||||||
const API_BASE = '/api/sync'
|
const API_BASE = '/api/sync'
|
||||||
const syncingLogbooks = new Set<string>()
|
const syncingLogbooks = new Set<string>()
|
||||||
@@ -177,10 +182,19 @@ async function pushChanges(logbookId: string): Promise<boolean> {
|
|||||||
const queueItem = pending[i]
|
const queueItem = pending[i]
|
||||||
if (!queueItem) continue
|
if (!queueItem) continue
|
||||||
|
|
||||||
if (res.status === 'success' || res.status === 'conflict') {
|
if (res.status === 'success') {
|
||||||
if (queueItem.id !== undefined) {
|
if (queueItem.id !== undefined) {
|
||||||
await db.syncQueue.delete(queueItem.id)
|
await db.syncQueue.delete(queueItem.id)
|
||||||
}
|
}
|
||||||
|
clearSyncConflict(logbookId, res.payloadId ?? queueItem.payloadId, queueItem.type)
|
||||||
|
} else if (res.status === 'conflict') {
|
||||||
|
reportSyncConflict({
|
||||||
|
logbookId,
|
||||||
|
payloadId: res.payloadId ?? queueItem.payloadId,
|
||||||
|
type: queueItem.type,
|
||||||
|
reason: typeof res.reason === 'string' ? res.reason : 'Server version is newer',
|
||||||
|
queueItemId: queueItem.id
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
console.error(`Sync failed for item ${res.payloadId}:`, res.error)
|
console.error(`Sync failed for item ${res.payloadId}:`, res.error)
|
||||||
}
|
}
|
||||||
@@ -525,3 +539,43 @@ export function stopBackgroundSync() {
|
|||||||
syncIntervalId = null
|
syncIntervalId = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Accept server version: pull latest and drop the conflicting queue item. */
|
||||||
|
export async function resolveSyncConflictUseServer(conflict: SyncConflict): Promise<void> {
|
||||||
|
if (conflict.queueItemId !== undefined) {
|
||||||
|
await db.syncQueue.delete(conflict.queueItemId)
|
||||||
|
} else {
|
||||||
|
const pending = await db.syncQueue
|
||||||
|
.where({ logbookId: conflict.logbookId })
|
||||||
|
.filter(
|
||||||
|
(item) => item.payloadId === conflict.payloadId && item.type === conflict.type
|
||||||
|
)
|
||||||
|
.toArray()
|
||||||
|
const ids = pending.map((p) => p.id).filter((id): id is number => id !== undefined)
|
||||||
|
if (ids.length > 0) await db.syncQueue.bulkDelete(ids)
|
||||||
|
}
|
||||||
|
clearSyncConflict(conflict.logbookId, conflict.payloadId, conflict.type)
|
||||||
|
await pullChanges(conflict.logbookId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep local version: bump queue timestamp and retry push. */
|
||||||
|
export async function resolveSyncConflictKeepLocal(conflict: SyncConflict): Promise<void> {
|
||||||
|
const bump = new Date(Date.now() + 1000).toISOString()
|
||||||
|
if (conflict.queueItemId !== undefined) {
|
||||||
|
await db.syncQueue.update(conflict.queueItemId, { updatedAt: bump })
|
||||||
|
} else {
|
||||||
|
const pending = await db.syncQueue
|
||||||
|
.where({ logbookId: conflict.logbookId })
|
||||||
|
.filter(
|
||||||
|
(item) => item.payloadId === conflict.payloadId && item.type === conflict.type
|
||||||
|
)
|
||||||
|
.toArray()
|
||||||
|
for (const item of pending) {
|
||||||
|
if (item.id !== undefined) {
|
||||||
|
await db.syncQueue.update(item.id, { updatedAt: bump })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clearSyncConflict(conflict.logbookId, conflict.payloadId, conflict.type)
|
||||||
|
await flushPushQueue(conflict.logbookId)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
export interface SyncConflict {
|
||||||
|
logbookId: string
|
||||||
|
payloadId: string
|
||||||
|
type: string
|
||||||
|
reason: string
|
||||||
|
queueItemId?: number
|
||||||
|
detectedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const conflicts = new Map<string, SyncConflict>()
|
||||||
|
const listeners = new Set<() => void>()
|
||||||
|
|
||||||
|
function conflictKey(logbookId: string, payloadId: string, type: string): string {
|
||||||
|
return `${logbookId}:${type}:${payloadId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSyncConflicts(logbookId?: string): SyncConflict[] {
|
||||||
|
const all = Array.from(conflicts.values())
|
||||||
|
if (!logbookId) return all
|
||||||
|
return all.filter((c) => c.logbookId === logbookId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasSyncConflicts(logbookId?: string): boolean {
|
||||||
|
return getSyncConflicts(logbookId).length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reportSyncConflict(conflict: Omit<SyncConflict, 'detectedAt'>): void {
|
||||||
|
const key = conflictKey(conflict.logbookId, conflict.payloadId, conflict.type)
|
||||||
|
conflicts.set(key, { ...conflict, detectedAt: new Date().toISOString() })
|
||||||
|
listeners.forEach((l) => l())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSyncConflict(logbookId: string, payloadId: string, type: string): void {
|
||||||
|
conflicts.delete(conflictKey(logbookId, payloadId, type))
|
||||||
|
listeners.forEach((l) => l())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSyncConflictsForLogbook(logbookId: string): void {
|
||||||
|
for (const key of conflicts.keys()) {
|
||||||
|
if (key.startsWith(`${logbookId}:`)) conflicts.delete(key)
|
||||||
|
}
|
||||||
|
listeners.forEach((l) => l())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeSyncConflicts(listener: () => void): () => void {
|
||||||
|
listeners.add(listener)
|
||||||
|
return () => listeners.delete(listener)
|
||||||
|
}
|
||||||
@@ -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 { apiFetch } from './api.js'
|
||||||
import { getOwmApiKeyForActiveUser } from './userPreferences.js'
|
import { getOwmApiKeyForActiveUser } from './userPreferences.js'
|
||||||
|
import {
|
||||||
|
type OwmAnalyticsSource,
|
||||||
|
PlausibleEvents,
|
||||||
|
trackPlausibleEvent
|
||||||
|
} from './analytics.js'
|
||||||
|
|
||||||
export class WeatherApiError extends Error {
|
export class WeatherApiError extends Error {
|
||||||
code: 'NO_KEY' | 'REQUEST_FAILED'
|
code: 'NO_KEY' | 'REQUEST_FAILED'
|
||||||
@@ -11,11 +16,16 @@ export class WeatherApiError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchOpenWeatherCurrent(params: {
|
const OWM_FETCH_TIMEOUT_MS = 20_000
|
||||||
|
|
||||||
|
export async function fetchOpenWeatherCurrent(
|
||||||
|
params: {
|
||||||
lat?: string
|
lat?: string
|
||||||
lon?: string
|
lon?: string
|
||||||
q?: string
|
q?: string
|
||||||
}): Promise<Record<string, unknown>> {
|
},
|
||||||
|
options?: { analyticsSource: OwmAnalyticsSource }
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
const searchParams = new URLSearchParams()
|
const searchParams = new URLSearchParams()
|
||||||
|
|
||||||
if (params.lat && params.lon) {
|
if (params.lat && params.lon) {
|
||||||
@@ -31,7 +41,22 @@ export async function fetchOpenWeatherCurrent(params: {
|
|||||||
const headers: Record<string, string> = {}
|
const headers: Record<string, string> = {}
|
||||||
if (userKey) headers['X-OWM-Api-Key'] = userKey
|
if (userKey) headers['X-OWM-Api-Key'] = userKey
|
||||||
|
|
||||||
const res = await apiFetch(`/api/weather/current?${searchParams.toString()}`, { headers })
|
const controller = new AbortController()
|
||||||
|
const timeoutId = window.setTimeout(() => controller.abort(), OWM_FETCH_TIMEOUT_MS)
|
||||||
|
let res: Response
|
||||||
|
try {
|
||||||
|
res = await apiFetch(`/api/weather/current?${searchParams.toString()}`, {
|
||||||
|
headers,
|
||||||
|
signal: controller.signal
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||||
|
throw new WeatherApiError('Weather request timed out')
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
|
||||||
if (res.status === 503) {
|
if (res.status === 503) {
|
||||||
throw new WeatherApiError('No OpenWeatherMap API key configured', 'NO_KEY')
|
throw new WeatherApiError('No OpenWeatherMap API key configured', 'NO_KEY')
|
||||||
@@ -42,5 +67,11 @@ export async function fetchOpenWeatherCurrent(params: {
|
|||||||
throw new WeatherApiError('Weather API rejected the request')
|
throw new WeatherApiError('Weather API rejected the request')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options?.analyticsSource) {
|
||||||
|
trackPlausibleEvent(PlausibleEvents.OWM_WEATHER_FETCHED, {
|
||||||
|
source: options.analyticsSource
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return data
|
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,16 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { crc32Hex, nmeaFileCrc32, normalizeNmeaTextForCrc } from './crc32.js'
|
||||||
|
|
||||||
|
describe('crc32', () => {
|
||||||
|
it('hashes known test vectors', () => {
|
||||||
|
expect(crc32Hex('')).toBe('00000000')
|
||||||
|
expect(crc32Hex('123456789')).toBe('CBF43926')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('normalizes line endings before hashing NMEA content', () => {
|
||||||
|
const a = nmeaFileCrc32('$GPRMC,123519,A\r\n$GPGGA,123519\r\n')
|
||||||
|
const b = nmeaFileCrc32('$GPRMC,123519,A\n$GPGGA,123519\n')
|
||||||
|
expect(a).toBe(b)
|
||||||
|
expect(normalizeNmeaTextForCrc('a\r\nb\r')).toBe('a\nb')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/** Normalize NMEA text so identical content hashes the same across platforms. */
|
||||||
|
export function normalizeNmeaTextForCrc(text: string): string {
|
||||||
|
return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trimEnd()
|
||||||
|
}
|
||||||
|
|
||||||
|
const CRC32_TABLE = (() => {
|
||||||
|
const table = new Uint32Array(256)
|
||||||
|
for (let i = 0; i < 256; i++) {
|
||||||
|
let c = i
|
||||||
|
for (let k = 0; k < 8; k++) {
|
||||||
|
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
|
||||||
|
}
|
||||||
|
table[i] = c >>> 0
|
||||||
|
}
|
||||||
|
return table
|
||||||
|
})()
|
||||||
|
|
||||||
|
/** CRC-32 (IEEE / Ethernet polynomial), uppercase 8-char hex. */
|
||||||
|
export function crc32Hex(text: string): string {
|
||||||
|
const bytes = new TextEncoder().encode(text)
|
||||||
|
let crc = 0xffffffff
|
||||||
|
for (const byte of bytes) {
|
||||||
|
crc = CRC32_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8)
|
||||||
|
}
|
||||||
|
return ((crc ^ 0xffffffff) >>> 0).toString(16).toUpperCase().padStart(8, '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nmeaFileCrc32(text: string): string {
|
||||||
|
return crc32Hex(normalizeNmeaTextForCrc(text))
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/** Map unknown errors to a user-facing message (i18n key or fallback). */
|
||||||
|
export function getErrorMessage(err: unknown, fallback: string): string {
|
||||||
|
if (err instanceof Error && err.message.trim()) {
|
||||||
|
return err.message
|
||||||
|
}
|
||||||
|
if (typeof err === 'string' && err.trim()) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/** Request durable IndexedDB storage (important on iOS Safari). */
|
||||||
|
export async function requestPersistentStorage(): Promise<{
|
||||||
|
persisted: boolean
|
||||||
|
supported: boolean
|
||||||
|
}> {
|
||||||
|
if (!('storage' in navigator) || !navigator.storage.persist) {
|
||||||
|
return { persisted: false, supported: false }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const persisted = await navigator.storage.persisted()
|
||||||
|
if (persisted) return { persisted: true, supported: true }
|
||||||
|
const granted = await navigator.storage.persist()
|
||||||
|
return { persisted: granted, supported: true }
|
||||||
|
} catch {
|
||||||
|
return { persisted: false, supported: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,8 @@ export default defineConfig({
|
|||||||
include: ['leaflet']
|
include: ['leaflet']
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
|
// Passkeys require localhost or a real domain — not 127.0.0.1
|
||||||
|
host: 'localhost',
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ services:
|
|||||||
container_name: daagbox-prod-db
|
container_name: daagbox-prod-db
|
||||||
restart: always
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
|
||||||
POSTGRES_DB: daagbox
|
POSTGRES_DB: ${POSTGRES_DB:-daagbox}
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U postgres -d daagbox"]
|
test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""]
|
||||||
|
# Not published to the host — reachable only on the Compose network (do not add ports: here)
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -23,9 +24,10 @@ services:
|
|||||||
restart: always
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
PORT: 5000
|
PORT: 5000
|
||||||
DATABASE_URL: "postgresql://postgres:postgres@db:5432/daagbox?schema=public"
|
DATABASE_URL: "postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-daagbox}?schema=public"
|
||||||
RP_ID: ${RP_ID:-localhost}
|
RP_ID: ${RP_ID:-localhost}
|
||||||
ORIGIN: ${ORIGIN:-http://localhost}
|
ORIGIN: ${ORIGIN:-http://localhost}
|
||||||
|
TRUST_PROXY: ${TRUST_PROXY:-1}
|
||||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
|
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
|
||||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
|
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
|
||||||
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:support@kapteins-daagbok.eu}
|
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:support@kapteins-daagbok.eu}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Deployment: Nginx Proxy Manager & Security (Sprint 1)
|
||||||
|
|
||||||
|
Kapteins Daagbok läuft öffentlich unter **https://kapteins-daagbok.eu/** hinter **Nginx Proxy Manager** (NPM, z. B. `172.16.10.10`) mit Upstream auf den App-Stack (`172.16.10.110`).
|
||||||
|
|
||||||
|
## NPM Proxy Host
|
||||||
|
|
||||||
|
| Einstellung | Wert |
|
||||||
|
|-------------|------|
|
||||||
|
| Domain | `kapteins-daagbok.eu` |
|
||||||
|
| Scheme | `https` |
|
||||||
|
| Forward Hostname / IP | `172.16.10.110` (oder Container-Port auf dem Host) |
|
||||||
|
| Forward Port | `80` (Frontend-Nginx) |
|
||||||
|
| Websockets | an, falls genutzt |
|
||||||
|
| Block Common Exploits | an |
|
||||||
|
| SSL | Let's Encrypt o. ä. |
|
||||||
|
|
||||||
|
### Custom Nginx (Advanced) — empfohlen
|
||||||
|
|
||||||
|
NPM setzt `X-Forwarded-*` in der Regel automatisch. Falls nicht, im Proxy-Host unter **Advanced**:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backend-Umgebung (`.env` auf dem Server)
|
||||||
|
|
||||||
|
```env
|
||||||
|
ORIGIN=https://kapteins-daagbok.eu
|
||||||
|
RP_ID=kapteins-daagbok.eu
|
||||||
|
SESSION_SECRET=<min. 32 Zeichen, openssl rand -base64 48>
|
||||||
|
TRUST_PROXY=172.16.10.10
|
||||||
|
# oder TRUST_PROXY=1 für genau einen Proxy-Hop
|
||||||
|
```
|
||||||
|
|
||||||
|
`ORIGIN` muss **exakt** der Browser-URL entsprechen (ohne trailing slash).
|
||||||
|
|
||||||
|
## Security-Header
|
||||||
|
|
||||||
|
- **HSTS, CSP (optional restriktiver):** können in NPM unter „Custom Headers“ oder im Advanced-Block gesetzt werden.
|
||||||
|
- **Basis-Header** für statische Dateien setzt [`client/nginx.conf`](../../client/nginx.conf) (X-Content-Type-Options, X-Frame-Options, Referrer-Policy, CSP inkl. Plausible).
|
||||||
|
|
||||||
|
### Plausible Analytics
|
||||||
|
|
||||||
|
Script-Host: `https://plausible.elpatron.me` — in CSP als `script-src` und `connect-src` erlaubt. Gemessene Site: `data-domain="kapteins-daagbok.eu"`.
|
||||||
|
|
||||||
|
Optional später: `analytics.kapteins-daagbok.eu` als Alias auf dieselbe Plausible-Instanz.
|
||||||
|
|
||||||
|
## Nach Deploy prüfen
|
||||||
|
|
||||||
|
1. https://kapteins-daagbok.eu/api/health — `status: ok`
|
||||||
|
2. Passkey Login / Registrierung
|
||||||
|
3. DevTools → Application → Cookie `daagbok_session`: `Secure`, `HttpOnly`, `SameSite=Lax`
|
||||||
|
4. Response-Header auf `index.html`: CSP, `X-Frame-Options`
|
||||||
|
5. Zwei Geräte hinter NAT: unabhängige Rate-Limits (nicht alle als eine IP)
|
||||||
|
|
||||||
|
## Docker Compose
|
||||||
|
|
||||||
|
Keine Default-Passwörter in Produktion: starkes `POSTGRES_PASSWORD` (siehe [postgres-password.md](postgres-password.md)) und `SESSION_SECRET` in `.env` setzen (siehe [`.env.example`](../../.env.example)).
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# PostgreSQL absichern (Produktion)
|
||||||
|
|
||||||
|
## Ist-Zustand
|
||||||
|
|
||||||
|
- Die Datenbank läuft im Container `daagbox-prod-db` **ohne** Host-Port (nur Docker-Netz `db:5432`) — gut.
|
||||||
|
- Das Passwort wird beim **ersten** Start des Volumes gesetzt; ein späteres Ändern nur von `POSTGRES_PASSWORD` in `.env` **ändert nicht** das laufende Passwort.
|
||||||
|
- Nach Sprint 1 war auf dem Server noch das Legacy-Passwort `postgres` möglich → per Skript rotieren.
|
||||||
|
|
||||||
|
## Empfohlene Schritte
|
||||||
|
|
||||||
|
1. **Backup/Snapshot** (hast du laut Vorgabe).
|
||||||
|
2. Auf dem Server im Repo:
|
||||||
|
```bash
|
||||||
|
cd /opt/kapteins-daagbok
|
||||||
|
git pull
|
||||||
|
chmod +x scripts/rotate-postgres-password.sh
|
||||||
|
./scripts/rotate-postgres-password.sh
|
||||||
|
```
|
||||||
|
3. Inhalt von `.postgres-credentials.<timestamp>` in den Passwort-Manager übernehmen, Datei auf dem Server löschen:
|
||||||
|
```bash
|
||||||
|
shred -u .postgres-credentials.* # oder rm nach manuellem Notieren
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optional: eigener App-Benutzer (statt `postgres` für Prisma)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/rotate-postgres-password.sh --app-user daagbok
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`daagbok`**: Login für Backend/Prisma (kein Superuser)
|
||||||
|
- **`postgres`**: nur noch Admin (Passwort in `POSTGRES_ADMIN_PASSWORD` in `.env`)
|
||||||
|
|
||||||
|
## Lokale Entwicklung
|
||||||
|
|
||||||
|
`scripts/start-dev.sh` nutzt weiterhin `postgres/postgres` auf localhost — nur für Dev. Produktion nie dieses Passwort wiederverwenden.
|
||||||
|
|
||||||
|
## Verifikation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec daagbox-prod-backend wget -qO- http://127.0.0.1:5000/api/health
|
||||||
|
curl -sf https://kapteins-daagbok.eu/api/health
|
||||||
|
```
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Pre-Deploy-Checks (ohne CI)
|
||||||
|
|
||||||
|
Vor jedem Update auf **https://kapteins-daagbok.eu/** lokal ausführen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run check
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Skript [`scripts/predeploy-check.sh`](../../scripts/predeploy-check.sh) führt aus:
|
||||||
|
|
||||||
|
1. i18n-Key-Validierung (`validate:i18n`)
|
||||||
|
2. Client: `test` → `build` (TypeScript via `tsc -b`)
|
||||||
|
3. Server: `test` → `build`
|
||||||
|
|
||||||
|
## Einzelbefehle (Repo-Root)
|
||||||
|
|
||||||
|
| Befehl | Inhalt |
|
||||||
|
|--------|--------|
|
||||||
|
| `npm run lint` | ESLint (Client) — optional, noch nicht Teil von `check` |
|
||||||
|
| `npm run test` | Vitest Client + Server |
|
||||||
|
| `npm run build` | Production-Build beider Pakete |
|
||||||
|
| `npm run predeploy` | Alias für `npm run check` |
|
||||||
|
|
||||||
|
## Server-Tests
|
||||||
|
|
||||||
|
Smoke-Tests in `server/src/api.smoke.test.ts` — keine echte Datenbank (Prisma gemockt). Prüfen u. a. Health, 401 ohne Session, öffentliche Collaboration-Validierung.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server && npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Nach erfolgreichem Check
|
||||||
|
|
||||||
|
[`scripts/update-prod.sh`](../../scripts/update-prod.sh) führt `predeploy-check.sh` **automatisch** aus (nach Release-Vorbereitung, vor dem SSH-Deploy).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/update-prod.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Notfall ohne Checks (nur wenn nötig): `SKIP_PREDEPLOY_CHECK=1 ./scripts/update-prod.sh`
|
||||||
|
|
||||||
|
Manuell auf dem Server: `git pull`, `docker compose build`, `docker compose up -d` (siehe [npm-security.md](npm-security.md)).
|
||||||
@@ -157,7 +157,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 2.5mm;
|
gap: 2.5mm;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
font-size: 10.5pt;
|
font-size: 8.5pt;
|
||||||
line-height: 1.28;
|
line-height: 1.28;
|
||||||
color: #e2e8f0;
|
color: #e2e8f0;
|
||||||
}
|
}
|
||||||
@@ -320,6 +320,8 @@
|
|||||||
<div class="feature"><span class="feature-icon">✦</span><span>Simpelt login uden adgangskode Passkey.</span></div>
|
<div class="feature"><span class="feature-icon">✦</span><span>Simpelt login uden adgangskode Passkey.</span></div>
|
||||||
<div class="feature"><span class="feature-icon">✦</span><span>Ende-til-ende-kryptering</span></div>
|
<div class="feature"><span class="feature-icon">✦</span><span>Ende-til-ende-kryptering</span></div>
|
||||||
<div class="feature"><span class="feature-icon">✦</span><span>Upload af GPS-spor (GPX/KML) med kortvisning</span></div>
|
<div class="feature"><span class="feature-icon">✦</span><span>Upload af GPS-spor (GPX/KML) med kortvisning</span></div>
|
||||||
|
<div class="feature"><span class="feature-icon">✦</span><span>Automatisk loggenerering fra NMEA-data</span></div>
|
||||||
|
<div class="feature"><span class="feature-icon">✦</span><span>Live-log (klik-til-log)</span></div>
|
||||||
<div class="feature"><span class="feature-icon">✦</span><span>Rute-statistik</span></div>
|
<div class="feature"><span class="feature-icon">✦</span><span>Rute-statistik</span></div>
|
||||||
<div class="feature"><span class="feature-icon">✦</span><span>Vedhæftede billeder pr. rejsedag</span></div>
|
<div class="feature"><span class="feature-icon">✦</span><span>Vedhæftede billeder pr. rejsedag</span></div>
|
||||||
<div class="feature"><span class="feature-icon">✦</span><span>Fotoavatarbilleder til skipper og besætning</span></div>
|
<div class="feature"><span class="feature-icon">✦</span><span>Fotoavatarbilleder til skipper og besætning</span></div>
|
||||||
|
|||||||
@@ -157,7 +157,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 2.5mm;
|
gap: 2.5mm;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
font-size: 10.5pt;
|
font-size: 8.5pt;
|
||||||
line-height: 1.28;
|
line-height: 1.28;
|
||||||
color: #e2e8f0;
|
color: #e2e8f0;
|
||||||
}
|
}
|
||||||
@@ -320,6 +320,8 @@
|
|||||||
<div class="feature"><span class="feature-icon">✦</span><span>Einfache passwortlose Passkey-Anmeldung</span></div>
|
<div class="feature"><span class="feature-icon">✦</span><span>Einfache passwortlose Passkey-Anmeldung</span></div>
|
||||||
<div class="feature"><span class="feature-icon">✦</span><span>Ende-zu-Ende Verschlüsselung</span></div>
|
<div class="feature"><span class="feature-icon">✦</span><span>Ende-zu-Ende Verschlüsselung</span></div>
|
||||||
<div class="feature"><span class="feature-icon">✦</span><span>GPS-Track Upload (GPX/KML) mit Kartendarstellung</span></div>
|
<div class="feature"><span class="feature-icon">✦</span><span>GPS-Track Upload (GPX/KML) mit Kartendarstellung</span></div>
|
||||||
|
<div class="feature"><span class="feature-icon">✦</span><span>Automatische Log-Erstellung aus NMEA-Daten</span></div>
|
||||||
|
<div class="feature"><span class="feature-icon">✦</span><span>Live-Log (Click-to-Log)</span></div>
|
||||||
<div class="feature"><span class="feature-icon">✦</span><span>Streckenstatistik</span></div>
|
<div class="feature"><span class="feature-icon">✦</span><span>Streckenstatistik</span></div>
|
||||||
<div class="feature"><span class="feature-icon">✦</span><span>Foto-Anhänge pro Reisetag</span></div>
|
<div class="feature"><span class="feature-icon">✦</span><span>Foto-Anhänge pro Reisetag</span></div>
|
||||||
<div class="feature"><span class="feature-icon">✦</span><span>Foto-Avatarbilder für Skipper und Crew</span></div>
|
<div class="feature"><span class="feature-icon">✦</span><span>Foto-Avatarbilder für Skipper und Crew</span></div>
|
||||||
|
|||||||
@@ -157,7 +157,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 2.5mm;
|
gap: 2.5mm;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
font-size: 10.5pt;
|
font-size: 8.5pt;
|
||||||
line-height: 1.28;
|
line-height: 1.28;
|
||||||
color: #e2e8f0;
|
color: #e2e8f0;
|
||||||
}
|
}
|
||||||
@@ -309,7 +309,7 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<p class="intro">
|
<p class="intro">
|
||||||
Oppbevar loggboken om bord digitalt: reisedager, GPS-spor, mannskaps- og skipsdata
|
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>Ende-til-ende-kryptert</strong>kan installeres som en app og
|
||||||
<strong>også offline</strong> kan brukes til sjøs.
|
<strong>også offline</strong> kan brukes til sjøs.
|
||||||
</p>
|
</p>
|
||||||
@@ -320,6 +320,8 @@
|
|||||||
<div class="feature"><span class="feature-icon">✦</span><span>Enkel passordfri Passkey-pålogging</span></div>
|
<div class="feature"><span class="feature-icon">✦</span><span>Enkel passordfri Passkey-pålogging</span></div>
|
||||||
<div class="feature"><span class="feature-icon">✦</span><span>Ende-til-ende-kryptering</span></div>
|
<div class="feature"><span class="feature-icon">✦</span><span>Ende-til-ende-kryptering</span></div>
|
||||||
<div class="feature"><span class="feature-icon">✦</span><span>Opplasting av GPS-spor (GPX/KML) med kartvisning</span></div>
|
<div class="feature"><span class="feature-icon">✦</span><span>Opplasting av GPS-spor (GPX/KML) med kartvisning</span></div>
|
||||||
|
<div class="feature"><span class="feature-icon">✦</span><span>Automatisk logggenerering fra NMEA-data</span></div>
|
||||||
|
<div class="feature"><span class="feature-icon">✦</span><span>Live-logg (klikk-til-logg)</span></div>
|
||||||
<div class="feature"><span class="feature-icon">✦</span><span>Rutestatistikk</span></div>
|
<div class="feature"><span class="feature-icon">✦</span><span>Rutestatistikk</span></div>
|
||||||
<div class="feature"><span class="feature-icon">✦</span><span>Fotobilag per reisedag</span></div>
|
<div class="feature"><span class="feature-icon">✦</span><span>Fotobilag per reisedag</span></div>
|
||||||
<div class="feature"><span class="feature-icon">✦</span><span>Avatarbilder for skipper og mannskap</span></div>
|
<div class="feature"><span class="feature-icon">✦</span><span>Avatarbilder for skipper og mannskap</span></div>
|
||||||
@@ -349,7 +351,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="beta-box">
|
<section class="beta-box">
|
||||||
<h2>Betafasen - dine tilbakemeldinger teller</h2>
|
<h2>Betafase - dine tilbakemeldinger teller</h2>
|
||||||
<p>
|
<p>
|
||||||
Kapteins Daagbok er en<strong>Privat hobbyprosjekt uten profitthensikt</strong>.
|
Kapteins Daagbok er en<strong>Privat hobbyprosjekt uten profitthensikt</strong>.
|
||||||
Som betatester bidrar du til å forbedre appen for skippere og mannskap i hverdagen - tilbakemeldinger er hjertelig velkomne.
|
Som betatester bidrar du til å forbedre appen for skippere og mannskap i hverdagen - tilbakemeldinger er hjertelig velkomne.
|
||||||
@@ -366,7 +368,7 @@
|
|||||||
<p>Åpne i nettleseren eller legg til som en app på startskjermen. Registrer deg med Passkey - ingen appbutikk er nødvendig.</p>
|
<p>Åpne i nettleseren eller legg til som en app på startskjermen. Registrer deg med Passkey - ingen appbutikk er nødvendig.</p>
|
||||||
<div class="tags">
|
<div class="tags">
|
||||||
<span class="tag">Kostnadsfritt</span>
|
<span class="tag">Kostnadsfritt</span>
|
||||||
<span class="tag">Gratis annonsering</span>
|
<span class="tag">Reklame gratis</span>
|
||||||
<span class="tag">E2E-kryptert</span>
|
<span class="tag">E2E-kryptert</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -157,7 +157,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 2.5mm;
|
gap: 2.5mm;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
font-size: 10.5pt;
|
font-size: 8.5pt;
|
||||||
line-height: 1.28;
|
line-height: 1.28;
|
||||||
color: #e2e8f0;
|
color: #e2e8f0;
|
||||||
}
|
}
|
||||||
@@ -320,6 +320,8 @@
|
|||||||
<div class="feature"><span class="feature-icon">✦</span><span>Enkel lösenordsfri Passkey-inloggning</span></div>
|
<div class="feature"><span class="feature-icon">✦</span><span>Enkel lösenordsfri Passkey-inloggning</span></div>
|
||||||
<div class="feature"><span class="feature-icon">✦</span><span>End-to-end-kryptering</span></div>
|
<div class="feature"><span class="feature-icon">✦</span><span>End-to-end-kryptering</span></div>
|
||||||
<div class="feature"><span class="feature-icon">✦</span><span>Uppladdning av GPS-spår (GPX/KML) med kartvisning</span></div>
|
<div class="feature"><span class="feature-icon">✦</span><span>Uppladdning av GPS-spår (GPX/KML) med kartvisning</span></div>
|
||||||
|
<div class="feature"><span class="feature-icon">✦</span><span>Automatisk logggenerering från NMEA-data</span></div>
|
||||||
|
<div class="feature"><span class="feature-icon">✦</span><span>Live-logg (klicka för att logga)</span></div>
|
||||||
<div class="feature"><span class="feature-icon">✦</span><span>Statistik över rutter</span></div>
|
<div class="feature"><span class="feature-icon">✦</span><span>Statistik över rutter</span></div>
|
||||||
<div class="feature"><span class="feature-icon">✦</span><span>Fotobilagor per resdag</span></div>
|
<div class="feature"><span class="feature-icon">✦</span><span>Fotobilagor per resdag</span></div>
|
||||||
<div class="feature"><span class="feature-icon">✦</span><span>Fotoavatarbilder för skeppare och besättning</span></div>
|
<div class="feature"><span class="feature-icon">✦</span><span>Fotoavatarbilder för skeppare och besättning</span></div>
|
||||||
|
|||||||
|
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 |
@@ -0,0 +1,165 @@
|
|||||||
|
# NMEA-Import — Recherche & Entscheidungsnotizen
|
||||||
|
|
||||||
|
Stand: 2026-05-31 · Status: **In Umsetzung** (`feature/nmea-journal-import`)
|
||||||
|
|
||||||
|
Anlass: Nutzeranfrage, ob Kapteins Daagbok um NMEA-Empfang erweiterbar sei.
|
||||||
|
|
||||||
|
## Kurzfassung
|
||||||
|
|
||||||
|
| Ansatz | Machbarkeit (PWA) | Empfehlung |
|
||||||
|
|--------|-------------------|------------|
|
||||||
|
| **Live-NMEA** (Serial/TCP/Bluetooth vom Plotter) | Praktisch nein (Browser-Sandbox, iOS) | Nicht als reine PWA versprechen |
|
||||||
|
| **NMEA-Dateiimport** | Ja (Parsing im Client) | Sinnvoller nächster Schritt, wenn überhaupt |
|
||||||
|
| **GPX-Import** (bereits vorhanden) | Ja | Für die meisten Freizeit-Skipper der praktischere Weg |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Aktueller Stand in Kapteins Daagbok
|
||||||
|
|
||||||
|
- **PWA** (installierbar, offline-fähig), kein nativer App-Store-Wrapper
|
||||||
|
- **Position:** `navigator.geolocation` (Geräte-GPS) in `LogEntryEditor.tsx`
|
||||||
|
- **Tracks:** Upload von **GPX/KML/GeoJSON** → Karte, Streckenstatistik (`trackUpload.ts`, `LogEntryEditor.tsx`)
|
||||||
|
- **Log-Ereignisse** u. a.: Zeit, MgK/rwk, Wind (Richtung/Stärke/Druck), Seegang, Wetter, Strom, Krängung, Segel/Motor, Log, Distanz, GPS-Koordinaten, Bemerkungen (`logEntryPayload.ts`)
|
||||||
|
|
||||||
|
Es gibt **keinen** NMEA-Parser und **keinen** Live-Datenstrom.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Warum Live-NMEA in einer PWA schwierig ist
|
||||||
|
|
||||||
|
Typische NMEA-Quellen an Bord und Browser-Fähigkeiten:
|
||||||
|
|
||||||
|
| Quelle | PWA-tauglich? |
|
||||||
|
|--------|----------------|
|
||||||
|
| USB/Serial (RS422/232) | Kaum — Web Serial API nur Chrome/Edge, **nicht iOS/Safari**, am Tablet/Phone selten praktikabel |
|
||||||
|
| TCP/UDP (z. B. Port 10110) | **Nein** — Browser haben keine Raw-Sockets |
|
||||||
|
| Bluetooth-NMEA | Sehr eingeschränkt (Web Bluetooth), iOS praktisch unbrauchbar |
|
||||||
|
| Handy-GPS | **Ja** — Geolocation API (bereits implementiert), aber **kein NMEA vom Plotter** |
|
||||||
|
|
||||||
|
Weitere PWA-Limits:
|
||||||
|
|
||||||
|
- Kein zuverlässiger **Hintergrundbetrieb** für kontinuierlichen Empfang
|
||||||
|
- **HTTPS-App → lokales Boot-Netz** (`192.168.x.x`): Mixed Content, CORS, ggf. Local-Network-Permissions
|
||||||
|
- iPad/iPhone als installierte PWA besonders restriktiv
|
||||||
|
|
||||||
|
**Umweg (später optional):** Gateway im Boot (z. B. SignalK) mit WebSocket/HTTP — PWA verbindet sich dann zu einem **Server**, nicht direkt zu NMEA. Setup-Aufwand, eher für Technikaffine.
|
||||||
|
|
||||||
|
**Native Hülle** (Capacitor, Electron, …) würde Serial/TCP/Bluetooth erweitern — wäre keine reine PWA mehr.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Was ist eine NMEA-Datei?
|
||||||
|
|
||||||
|
**NMEA 0183** = textbasiertes Protokoll aus **Einzelzeilen-Sätzen**, z. B.:
|
||||||
|
|
||||||
|
```
|
||||||
|
$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A
|
||||||
|
$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47
|
||||||
|
$HDT,274.3,T*2F
|
||||||
|
$MWV,274.5,R,15.2,N,A*2B
|
||||||
|
$DPT,12.4,0.5*42
|
||||||
|
```
|
||||||
|
|
||||||
|
Eine `.nmea`- oder `.log`-Datei ist ein **Zeitstempel-Stream** — alles, was der Logger in diesem Zeitraum mitgeschrieben hat.
|
||||||
|
|
||||||
|
**Nicht alle Telemetriedaten sind garantiert enthalten.** Es hängt ab von:
|
||||||
|
|
||||||
|
1. Sensoren an Bord (GPS ja, Wind nur mit Windgeber, …)
|
||||||
|
2. Logger-/Multiplexer-Konfiguration
|
||||||
|
3. Empfang während der Aufzeichnung
|
||||||
|
|
||||||
|
Ein reiner GPS-Logger liefert praktisch nur Position/Kurs/Fahrt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Was könnte ein NMEA-Dateiimport in der App bewirken?
|
||||||
|
|
||||||
|
Mapping zu bestehenden Logbuch-Feldern (Auszug):
|
||||||
|
|
||||||
|
| NMEA-Satz (Beispiel) | Inhalt | Nutzen |
|
||||||
|
|----------------------|--------|--------|
|
||||||
|
| RMC / GGA / GLL | Position, Zeit, oft SOG/COG | GPS-Koordinaten, **Track** (analog GPX), Kurs |
|
||||||
|
| VTG / VHW | Fahrt über Grund/Wasser, Kurs | Streckenstatistik, Kursfelder |
|
||||||
|
| HDT / HDG / HDM | Peilung/Kompass | MgK/rwk-Vorschläge |
|
||||||
|
| MWV / MWD | Wind | Windfelder im Reisetag |
|
||||||
|
| DPT / DBT | Tiefe | aktuell kein eigenes Feld |
|
||||||
|
| MTW | Wassertemperatur | ggf. Bemerkungen |
|
||||||
|
| XDR | diverse Transducer | abhängig vom Gerät |
|
||||||
|
|
||||||
|
**Mehrwert gegenüber GPX:**
|
||||||
|
|
||||||
|
- Track **plus** zeitlich zugeordnete Wind-/Kursdaten (wenn in der Datei vorhanden)
|
||||||
|
- Automatisches Vorschlagen von Log-Ereignissen aus Bord-Sensoren
|
||||||
|
- Eine Quelle (Bordanlage) statt nur Handy-GPS
|
||||||
|
|
||||||
|
**Was NMEA typischerweise nicht liefert** (bleibt manuell / Wetter-API):
|
||||||
|
|
||||||
|
- Seegang, Wetter-Symbolik, Strom, Krängung
|
||||||
|
- Crew, Hafen, Bemerkungen, Tankstände
|
||||||
|
- Segel/Motor-Konfiguration im nautischen Sinne
|
||||||
|
|
||||||
|
NMEA = **Sensor-Telemetrie**, kein **Skipper-Logbuch**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wird NMEA an Bord üblicherweise aufgezeichnet & exportiert?
|
||||||
|
|
||||||
|
**Teilweise — selten so einfach wie GPX für Endnutzer.**
|
||||||
|
|
||||||
|
| Quelle | Typischer Export | Einfach für Freizeit-Skipper? |
|
||||||
|
|--------|------------------|-------------------------------|
|
||||||
|
| Chartplotter (Garmin, Raymarine, B&G, …) | **GPX** (Track/Route) | ✅ oft (SD/USB/App) |
|
||||||
|
| Chartplotter | Roh-NMEA | ⚠️ selten direkt |
|
||||||
|
| WiFi-Multiplexer, SignalK, Raspi | NMEA-Datei oder Stream | ⚠️ Technikaffine |
|
||||||
|
| PC-Software (OpenCPN, …) | NMEA-Log | ⚠️ |
|
||||||
|
|
||||||
|
**GPX ist der de-facto-Standard** für „Track mit nach Hause nehmen“. NMEA-Rohlogs sind Nischen- oder Profi-/Tüftler-Setup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mögliche Roadmap (wenn wir es angehen)
|
||||||
|
|
||||||
|
### Phase 1 — NMEA-Dateiimport (PWA-kompatibel)
|
||||||
|
|
||||||
|
- Parser für gängige Sätze: RMC, GGA, GLL, VTG, optional MWV/MWD, HDT
|
||||||
|
- Track aus Positions-Sätzen (wie GPX-Pipeline)
|
||||||
|
- UI: Upload neben GPX/KML in `LogEntryEditor`
|
||||||
|
- Checksummen-Validierung (`*XX`), Encoding, gemischte Talker-IDs (GP, GN, …)
|
||||||
|
|
||||||
|
### Phase 2 — Anreicherung Log-Ereignisse
|
||||||
|
|
||||||
|
- Aus NMEA-Stream pro Zeitpunkt Wind/Kurs/Position in Log-Events vorschlagen
|
||||||
|
- Nutzer bestätigt/korrigiert (kein blindes Überschreiben)
|
||||||
|
|
||||||
|
### Phase 3 — optional, nicht PWA-pur
|
||||||
|
|
||||||
|
- SignalK-WebSocket (Nutzer konfiguriert Boot-URL)
|
||||||
|
- Oder native Wrapper / Companion-Bridge
|
||||||
|
|
||||||
|
**Nicht empfohlen als Phase 1:** Live-NMEA direkt aus der PWA.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Antwortvorlage für Nutzer
|
||||||
|
|
||||||
|
> Als reine Browser-App können wir keinen direkten NMEA-Anschluss (Serial/TCP vom Plotter) zuverlässig anbieten — mobile Browser erlauben das nicht, besonders auf iPhone/iPad.
|
||||||
|
> Position über Handy-GPS und GPX-Tracks (Export vom Plotter oder Nav-App) funktionieren bereits.
|
||||||
|
> Ein **Import von NMEA-Dateien** (vom Gateway oder Logger) ist grundsätzlich denkbar und könnte Track plus ggf. Wind/Kurs ins Logbuch übernehmen — das prüfen wir für eine spätere Version.
|
||||||
|
> Für die meisten Skipper ist **GPX vom Plotter** der einfachere Weg.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Offene Fragen für spätere Planung
|
||||||
|
|
||||||
|
- Welche NMEA-Varianten melden Nutzer realistisch (0183 vs. NMEA 2000 nur über Gateway)?
|
||||||
|
- Reicht Parser-Abdeckung für 95 % der Dateien mit RMC+GGA+MWV?
|
||||||
|
- Sollen importierte Rohdaten gespeichert werden oder nur abgeleitete GPX/Events?
|
||||||
|
- Datenschutz: NMEA-Datei lokal parsen, nichts an Server senden (passt zu E2E-Modell)
|
||||||
|
- Plausible-Event analog `GPS Track Uploaded` → z. B. `NMEA File Imported`?
|
||||||
|
|
||||||
|
## Referenzen
|
||||||
|
|
||||||
|
- [NMEA 0183](https://www.nmea.org/) — Protokollstandard
|
||||||
|
- [SignalK](https://signalk.org/) — moderne Boot-API, WebSocket
|
||||||
|
- [Web Serial API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API) — Browser, eingeschränkt
|
||||||
|
- Bestehender Code: `client/src/services/trackUpload.ts`, `client/src/components/LogEntryEditor.tsx`
|
||||||
@@ -21,6 +21,8 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
|
|||||||
| Travel Day Saved | Reisetag gespeichert (`LogEntryEditor.tsx`) | — |
|
| Travel Day Saved | Reisetag gespeichert (`LogEntryEditor.tsx`) | — |
|
||||||
| Entry Signed | Passkey-Signatur Skipper oder Crew (`LogEntryEditor.tsx`) | `role`: `skipper` \| `crew` |
|
| Entry Signed | Passkey-Signatur Skipper oder Crew (`LogEntryEditor.tsx`) | `role`: `skipper` \| `crew` |
|
||||||
| GPS Track Uploaded | GPX/KML/GeoJSON hochgeladen (`LogEntryEditor.tsx`) | — |
|
| GPS Track Uploaded | GPX/KML/GeoJSON hochgeladen (`LogEntryEditor.tsx`) | — |
|
||||||
|
| 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`) | — |
|
| Vessel Saved | Schiffsdaten gespeichert (`VesselForm.tsx`) | — |
|
||||||
| Crew Saved | Skipper- oder Crew-Profil gespeichert (`CrewForm.tsx`) | `role`: `skipper` \| `crew`, `action`: `create` \| `update` |
|
| Crew Saved | Skipper- oder Crew-Profil gespeichert (`CrewForm.tsx`) | `role`: `skipper` \| `crew`, `action`: `create` \| `update` |
|
||||||
| Account Deleted | Konto erfolgreich gelöscht (`auth.ts`) | — |
|
| Account Deleted | Konto erfolgreich gelöscht (`auth.ts`) | — |
|
||||||
@@ -34,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` |
|
| PDF Exported | PDF-Export eines Reisetags (`LogEntryEditor.tsx`, `LogEntriesList.tsx`) | `scope`: `entry` |
|
||||||
| CSV Exported | CSV-Download aus der Eintragsliste (`LogEntriesList.tsx`) | — |
|
| CSV Exported | CSV-Download aus der Eintragsliste (`LogEntriesList.tsx`) | — |
|
||||||
| CSV Shared | CSV über Web Share API geteilt (`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 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` |
|
| Backup Restored | Backup wiederhergestellt (`LogbookBackupPanel.tsx`) | `entries`, `photos`, `mode`: `same_id` \| `overwrite` \| `new_id` |
|
||||||
| Push Enabled | Crew-Änderungs-Push aktiviert (`PushNotificationSettings.tsx`) | — |
|
| Push Enabled | Crew-Änderungs-Push aktiviert (`PushNotificationSettings.tsx`) | — |
|
||||||
@@ -50,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`) | — |
|
| Device Forgotten | Account aus Schnell-Login-Liste dieses Geräts entfernt (`UserProfilePage.tsx`) | — |
|
||||||
| Recovery Rotated | Neuer 12-Wörter-Wiederherstellungsschlüssel erstellt (`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`) |
|
| 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
|
## Bewusst nicht getrackt
|
||||||
|
|
||||||
@@ -58,11 +101,15 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
|
|||||||
- **PII:** Keine Inhalte aus verschlüsselten Logbüchern in Properties.
|
- **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).
|
- **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.
|
- **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.
|
- **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)
|
## Typische Funnels (Plausible Goals)
|
||||||
|
|
||||||
Empfohlene Goal-Ketten für Auswertung:
|
Empfohlene Goal-Ketten für Auswertung (nur Business!):
|
||||||
|
|
||||||
1. **Aktivierung:** Account Created → Logbook Created → Travel Day Created → Travel Day Saved
|
1. **Aktivierung:** Account Created → Logbook Created → Travel Day Created → Travel Day Saved
|
||||||
2. **Onboarding:** Account Created → Onboarding Tour Completed (vs. Onboarding Tour Skipped)
|
2. **Onboarding:** Account Created → Onboarding Tour Completed (vs. Onboarding Tour Skipped)
|
||||||
@@ -72,6 +119,9 @@ Empfohlene Goal-Ketten für Auswertung:
|
|||||||
6. **Datensicherung:** Backup Exported → Backup Restored
|
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)
|
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)
|
8. **Internationalisierung:** Language Changed (Verteilung `to`, Pfade mit Übersetzungs-Feedback)
|
||||||
|
9. **NMEA-Import:** NMEA Uploaded → NMEA Imported (Modus, `events`, optional Track; Upload-Funnel vs. Abbruch)
|
||||||
|
10. **Live-Journal:** Live Log Opened → Live Log Event Logged (Verteilung `action`; z. B. `fix`, `course`, `motor_start`) → Live Log Photo Uploaded
|
||||||
|
11. **OpenWeatherMap:** OWM Weather Fetched (Verteilung `source`; Live-Journal vs. Reisetag-Editor)
|
||||||
|
|
||||||
## Entwicklung
|
## Entwicklung
|
||||||
|
|
||||||
@@ -81,6 +131,11 @@ import { PlausibleEvents, trackPlausibleEvent } from './services/analytics.js'
|
|||||||
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
|
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
|
||||||
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'skipper' })
|
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'skipper' })
|
||||||
trackPlausibleEvent(PlausibleEvents.LANGUAGE_CHANGED, { from: 'de', to: 'da' })
|
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.
|
Lokal ohne Plausible-Script ist `trackPlausibleEvent` ein No-Op. In Production im Browser-Netzwerk-Tab auf Requests an die Plausible-Instanz prüfen.
|
||||||
|
|||||||
@@ -7,6 +7,11 @@
|
|||||||
"translate:flyer": "node scripts/translate-flyer.mjs",
|
"translate:flyer": "node scripts/translate-flyer.mjs",
|
||||||
"validate:i18n": "node scripts/validate-i18n-keys.mjs",
|
"validate:i18n": "node scripts/validate-i18n-keys.mjs",
|
||||||
"generate:flyer": "node scripts/generate-beta-flyer.mjs",
|
"generate:flyer": "node scripts/generate-beta-flyer.mjs",
|
||||||
"generate:flyer:all": "node scripts/generate-beta-flyer.mjs --all"
|
"generate:flyer:all": "node scripts/generate-beta-flyer.mjs --all",
|
||||||
|
"lint": "npm run lint --prefix client",
|
||||||
|
"test": "npm run test --prefix client && npm run test --prefix server",
|
||||||
|
"build": "npm run build --prefix client && npm run build --prefix server",
|
||||||
|
"check": "bash scripts/predeploy-check.sh",
|
||||||
|
"predeploy": "npm run check"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Local quality gates before deploying to kapteins-daagbok.eu (no external CI).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
echo "=================================================="
|
||||||
|
echo " Kapteins Daagbok — pre-deploy checks"
|
||||||
|
echo "=================================================="
|
||||||
|
|
||||||
|
run() {
|
||||||
|
echo ""
|
||||||
|
echo "==> $*"
|
||||||
|
"$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
run npm run validate:i18n
|
||||||
|
|
||||||
|
pushd client >/dev/null
|
||||||
|
if [ ! -d node_modules ]; then
|
||||||
|
run npm ci
|
||||||
|
fi
|
||||||
|
# Lint: run separately with `npm run lint` (client ESLint; cleanup tracked separately)
|
||||||
|
run npm run test
|
||||||
|
run npm run build
|
||||||
|
popd >/dev/null
|
||||||
|
|
||||||
|
pushd server >/dev/null
|
||||||
|
if [ ! -d node_modules ]; then
|
||||||
|
run npm ci
|
||||||
|
fi
|
||||||
|
run npm run test
|
||||||
|
run npm run build
|
||||||
|
popd >/dev/null
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=================================================="
|
||||||
|
echo " All pre-deploy checks passed."
|
||||||
|
echo "=================================================="
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Rotate PostgreSQL password on a running Docker Compose stack (existing volume safe).
|
||||||
|
#
|
||||||
|
# The Postgres image only applies POSTGRES_PASSWORD on first init; for existing data
|
||||||
|
# you must ALTER USER inside the running database, then update .env and restart backend.
|
||||||
|
#
|
||||||
|
# Usage (on server in repo root, with backup/snapshot taken):
|
||||||
|
# ./scripts/rotate-postgres-password.sh
|
||||||
|
# ./scripts/rotate-postgres-password.sh --app-user daagbok # optional: dedicated app role
|
||||||
|
#
|
||||||
|
# Writes the new credentials once to .postgres-credentials.<timestamp> (mode 600).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ENV_FILE="${ENV_FILE:-.env}"
|
||||||
|
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}"
|
||||||
|
DB_CONTAINER="${DB_CONTAINER:-daagbox-prod-db}"
|
||||||
|
BACKEND_CONTAINER="${BACKEND_CONTAINER:-daagbox-prod-backend}"
|
||||||
|
CREATE_APP_USER=""
|
||||||
|
APP_USER_NAME="daagbok"
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--app-user)
|
||||||
|
CREATE_APP_USER=1
|
||||||
|
APP_USER_NAME="${2:-daagbok}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
sed -n '2,12p' "$0"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown option: $1" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ ! -f "$ENV_FILE" ]; then
|
||||||
|
echo "Error: $ENV_FILE not found" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
set -a
|
||||||
|
source "$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
POSTGRES_USER="${POSTGRES_USER:-postgres}"
|
||||||
|
POSTGRES_DB="${POSTGRES_DB:-daagbox}"
|
||||||
|
OLD_PASSWORD="${POSTGRES_PASSWORD:-}"
|
||||||
|
|
||||||
|
if [ -z "$OLD_PASSWORD" ]; then
|
||||||
|
echo "Error: POSTGRES_PASSWORD not set in $ENV_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
NEW_PASSWORD="$(openssl rand -hex 24)"
|
||||||
|
NEW_APP_PASSWORD=""
|
||||||
|
if [ -n "$CREATE_APP_USER" ]; then
|
||||||
|
NEW_APP_PASSWORD="$(openssl rand -hex 24)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
BACKUP_ENV="${ENV_FILE}.bak.pg-rotate.$(date +%Y%m%d-%H%M%S)"
|
||||||
|
cp "$ENV_FILE" "$BACKUP_ENV"
|
||||||
|
echo "Backed up $ENV_FILE → $BACKUP_ENV"
|
||||||
|
|
||||||
|
echo "Rotating password for PostgreSQL role: $POSTGRES_USER (database: $POSTGRES_DB)"
|
||||||
|
|
||||||
|
# Escape single quotes for SQL string literals
|
||||||
|
sql_escape() {
|
||||||
|
printf "%s" "$1" | sed "s/'/''/g"
|
||||||
|
}
|
||||||
|
NEW_PW_SQL="$(sql_escape "$NEW_PASSWORD")"
|
||||||
|
|
||||||
|
export PGPASSWORD="$OLD_PASSWORD"
|
||||||
|
if ! docker exec -e PGPASSWORD="$OLD_PASSWORD" "$DB_CONTAINER" \
|
||||||
|
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 \
|
||||||
|
-c "ALTER USER \"${POSTGRES_USER}\" WITH PASSWORD '${NEW_PW_SQL}';" >/dev/null; then
|
||||||
|
echo "Error: ALTER USER failed. Is POSTGRES_PASSWORD in .env still correct?" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
unset PGPASSWORD
|
||||||
|
|
||||||
|
TARGET_USER="$POSTGRES_USER"
|
||||||
|
TARGET_PASSWORD="$NEW_PASSWORD"
|
||||||
|
|
||||||
|
if [ -n "$CREATE_APP_USER" ]; then
|
||||||
|
APP_PW_SQL="$(sql_escape "$NEW_APP_PASSWORD")"
|
||||||
|
export PGPASSWORD="$NEW_PASSWORD"
|
||||||
|
docker exec -e PGPASSWORD="$NEW_PASSWORD" "$DB_CONTAINER" psql -U postgres -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 <<SQL
|
||||||
|
DO \$\$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${APP_USER_NAME}') THEN
|
||||||
|
CREATE ROLE ${APP_USER_NAME} LOGIN PASSWORD '${APP_PW_SQL}';
|
||||||
|
ELSE
|
||||||
|
ALTER ROLE ${APP_USER_NAME} WITH LOGIN PASSWORD '${APP_PW_SQL}';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
\$\$;
|
||||||
|
GRANT CONNECT ON DATABASE ${POSTGRES_DB} TO ${APP_USER_NAME};
|
||||||
|
GRANT USAGE, CREATE ON SCHEMA public TO ${APP_USER_NAME};
|
||||||
|
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO ${APP_USER_NAME};
|
||||||
|
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO ${APP_USER_NAME};
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO ${APP_USER_NAME};
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO ${APP_USER_NAME};
|
||||||
|
SQL
|
||||||
|
unset PGPASSWORD
|
||||||
|
TARGET_USER="$APP_USER_NAME"
|
||||||
|
TARGET_PASSWORD="$NEW_APP_PASSWORD"
|
||||||
|
echo "Created/updated application role: $APP_USER_NAME (postgres superuser password also rotated)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Update .env without exposing values in process list longer than necessary
|
||||||
|
python3 - "$ENV_FILE" "$TARGET_USER" "$TARGET_PASSWORD" "$NEW_PASSWORD" "$CREATE_APP_USER" <<'PY'
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
path = Path(sys.argv[1])
|
||||||
|
target_user = sys.argv[2]
|
||||||
|
target_password = sys.argv[3]
|
||||||
|
postgres_password = sys.argv[4]
|
||||||
|
use_app_user = sys.argv[5] == "1"
|
||||||
|
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
def set_var(name: str, value: str, content: str) -> str:
|
||||||
|
pattern = rf"^{re.escape(name)}=.*$"
|
||||||
|
line = f"{name}={value}"
|
||||||
|
if re.search(pattern, content, flags=re.M):
|
||||||
|
return re.sub(pattern, line, content, count=1, flags=re.M)
|
||||||
|
return content.rstrip() + "\n" + line + "\n"
|
||||||
|
|
||||||
|
text = set_var("POSTGRES_USER", target_user, text)
|
||||||
|
text = set_var("POSTGRES_PASSWORD", target_password, text)
|
||||||
|
text = set_var("POSTGRES_DB", "daagbox", text) if "POSTGRES_DB=" not in text else text
|
||||||
|
if use_app_user:
|
||||||
|
text = set_var("POSTGRES_ADMIN_PASSWORD", postgres_password, text)
|
||||||
|
|
||||||
|
path.write_text(text, encoding="utf-8")
|
||||||
|
PY
|
||||||
|
|
||||||
|
CREDS_FILE=".postgres-credentials.$(date +%Y%m%d-%H%M%S)"
|
||||||
|
umask 077
|
||||||
|
{
|
||||||
|
echo "# Generated $(date -Iseconds) — store in password manager, then delete this file."
|
||||||
|
echo "POSTGRES_USER=$TARGET_USER"
|
||||||
|
echo "POSTGRES_PASSWORD=$TARGET_PASSWORD"
|
||||||
|
echo "POSTGRES_DB=$POSTGRES_DB"
|
||||||
|
if [ -n "$CREATE_APP_USER" ]; then
|
||||||
|
echo "POSTGRES_ADMIN_USER=postgres"
|
||||||
|
echo "POSTGRES_ADMIN_PASSWORD=$NEW_PASSWORD"
|
||||||
|
fi
|
||||||
|
} > "$CREDS_FILE"
|
||||||
|
chmod 600 "$CREDS_FILE"
|
||||||
|
echo "Credentials written to $CREDS_FILE (chmod 600)"
|
||||||
|
|
||||||
|
echo "Recreating backend (and db if compose env changed) to pick up DATABASE_URL..."
|
||||||
|
docker compose -f "$COMPOSE_FILE" up -d --force-recreate backend
|
||||||
|
|
||||||
|
echo "Waiting for backend health..."
|
||||||
|
for _ in $(seq 1 45); do
|
||||||
|
status="$(docker inspect --format='{{.State.Health.Status}}' "$BACKEND_CONTAINER" 2>/dev/null || echo missing)"
|
||||||
|
if [ "$status" = healthy ]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
export PGPASSWORD="$TARGET_PASSWORD"
|
||||||
|
docker exec -e PGPASSWORD="$TARGET_PASSWORD" "$DB_CONTAINER" \
|
||||||
|
psql -U "$TARGET_USER" -d "$POSTGRES_DB" -tAc 'SELECT count(*) FROM "User";' >/dev/null
|
||||||
|
unset PGPASSWORD
|
||||||
|
|
||||||
|
if curl -sf http://127.0.0.1/api/health | grep -q '"status":"ok"'; then
|
||||||
|
echo "OK: /api/health and DB connection verified."
|
||||||
|
else
|
||||||
|
echo "Warning: health check failed — see: docker compose logs backend" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Done. Remove $CREDS_FILE after saving credentials securely."
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Patch production .env for Sprint 1 docker-compose (POSTGRES_* + TRUST_PROXY).
|
||||||
|
# Safe: does not overwrite existing keys. Run on the server in /opt/kapteins-daagbok.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ENV_FILE="${1:-.env}"
|
||||||
|
|
||||||
|
if [ ! -f "$ENV_FILE" ]; then
|
||||||
|
echo "Error: $ENV_FILE not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
backup="${ENV_FILE}.bak.$(date +%Y%m%d-%H%M%S)"
|
||||||
|
cp "$ENV_FILE" "$backup"
|
||||||
|
echo "Backup: $backup"
|
||||||
|
|
||||||
|
ensure_var() {
|
||||||
|
local key="$1"
|
||||||
|
local value="$2"
|
||||||
|
if grep -q "^${key}=" "$ENV_FILE"; then
|
||||||
|
echo " keep ${key} (already set)"
|
||||||
|
else
|
||||||
|
echo "${key}=${value}" >> "$ENV_FILE"
|
||||||
|
echo " add ${key}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Patching $ENV_FILE for Sprint 1..."
|
||||||
|
# Match running container (docker exec daagbox-prod-db: USER=postgres DB=daagbox)
|
||||||
|
ensure_var POSTGRES_USER "postgres"
|
||||||
|
ensure_var POSTGRES_DB "daagbox"
|
||||||
|
if ! grep -q "^POSTGRES_PASSWORD=" "$ENV_FILE" || grep -q "^POSTGRES_PASSWORD=$" "$ENV_FILE"; then
|
||||||
|
echo " skip POSTGRES_PASSWORD (set manually or run scripts/rotate-postgres-password.sh)"
|
||||||
|
else
|
||||||
|
echo " keep POSTGRES_PASSWORD (already set)"
|
||||||
|
fi
|
||||||
|
# NPM on 172.16.10.10 → app on this host
|
||||||
|
ensure_var TRUST_PROXY "172.16.10.10"
|
||||||
|
|
||||||
|
echo "Done. Verify with: docker exec daagbox-prod-db psql -U postgres -d daagbox -c 'SELECT 1'"
|
||||||
@@ -125,6 +125,15 @@ prepare_release() {
|
|||||||
|
|
||||||
prepare_release
|
prepare_release
|
||||||
|
|
||||||
|
if [[ "${SKIP_PREDEPLOY_CHECK:-}" == "1" ]]; then
|
||||||
|
echo "Skipping pre-deploy checks (SKIP_PREDEPLOY_CHECK=1)."
|
||||||
|
else
|
||||||
|
echo "=================================================="
|
||||||
|
echo " Pre-deploy checks (local)"
|
||||||
|
echo "=================================================="
|
||||||
|
"$SCRIPT_DIR/predeploy-check.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "=================================================="
|
echo "=================================================="
|
||||||
echo "Deploying ${APP_VERSION} to ${REMOTE_TARGET}:${REMOTE_DIR}"
|
echo "Deploying ${APP_VERSION} to ${REMOTE_TARGET}:${REMOTE_DIR}"
|
||||||
echo "=================================================="
|
echo "=================================================="
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"dev": "tsx watch src/index.ts"
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^5.10.2",
|
"@prisma/client": "^5.10.2",
|
||||||
@@ -26,8 +28,11 @@
|
|||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/node": "^20.11.24",
|
"@types/node": "^20.11.24",
|
||||||
|
"@types/supertest": "^6.0.3",
|
||||||
"@types/web-push": "^3.6.4",
|
"@types/web-push": "^3.6.4",
|
||||||
|
"supertest": "^7.1.0",
|
||||||
"tsx": "^4.7.1",
|
"tsx": "^4.7.1",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3",
|
||||||
|
"vitest": "^3.0.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, it, expect, vi, beforeAll } from 'vitest'
|
||||||
|
import request from 'supertest'
|
||||||
|
|
||||||
|
vi.mock('./db.js', () => ({
|
||||||
|
prisma: {
|
||||||
|
$queryRaw: vi.fn().mockResolvedValue([{ '?column?': 1 }])
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { createApp } = await import('./app.js')
|
||||||
|
|
||||||
|
describe('API smoke', () => {
|
||||||
|
const app = createApp()
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
process.env.SESSION_SECRET =
|
||||||
|
process.env.SESSION_SECRET ?? 'test-session-secret-minimum-32-characters-long'
|
||||||
|
process.env.ORIGIN = process.env.ORIGIN ?? 'http://localhost:5173'
|
||||||
|
process.env.RP_ID = process.env.RP_ID ?? 'localhost'
|
||||||
|
})
|
||||||
|
|
||||||
|
it('GET /api/health returns ok when database is reachable', async () => {
|
||||||
|
const res = await request(app).get('/api/health')
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(res.body.status).toBe('ok')
|
||||||
|
expect(res.body.database).toBe('connected')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('GET /api/logbooks requires session', async () => {
|
||||||
|
const res = await request(app).get('/api/logbooks')
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
expect(res.body.error).toMatch(/Unauthorized/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('POST /api/sync/push requires session', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/sync/push')
|
||||||
|
.send({ items: [] })
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
expect(res.body.error).toMatch(/Unauthorized/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('GET /api/collaboration/invite-details requires token query', async () => {
|
||||||
|
const res = await request(app).get('/api/collaboration/invite-details')
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
expect(res.body.error).toMatch(/Token/i)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import express from 'express'
|
||||||
|
import cors from 'cors'
|
||||||
|
import cookieParser from 'cookie-parser'
|
||||||
|
import helmet from 'helmet'
|
||||||
|
import rateLimit from 'express-rate-limit'
|
||||||
|
import authRouter from './routes/auth.js'
|
||||||
|
import logbooksRouter from './routes/logbooks.js'
|
||||||
|
import syncRouter from './routes/sync.js'
|
||||||
|
import collaborationRouter from './routes/collaboration.js'
|
||||||
|
import signRouter from './routes/sign.js'
|
||||||
|
import pushRouter from './routes/push.js'
|
||||||
|
import weatherRouter from './routes/weather.js'
|
||||||
|
import feedbackRouter from './routes/feedback.js'
|
||||||
|
import { prisma } from './db.js'
|
||||||
|
import { buildCorsOptions } from './cors.js'
|
||||||
|
|
||||||
|
/** Behind Nginx Proxy Manager. See docs/deployment/npm-security.md */
|
||||||
|
function configureTrustProxy(app: express.Express): void {
|
||||||
|
const raw = process.env.TRUST_PROXY?.trim()
|
||||||
|
if (raw === '1' || raw === 'true') {
|
||||||
|
app.set('trust proxy', 1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (raw) {
|
||||||
|
app.set('trust proxy', raw)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
app.set('trust proxy', 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createApp(): express.Express {
|
||||||
|
const app = express()
|
||||||
|
|
||||||
|
configureTrustProxy(app)
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
helmet({
|
||||||
|
contentSecurityPolicy: false,
|
||||||
|
crossOriginEmbedderPolicy: false
|
||||||
|
})
|
||||||
|
)
|
||||||
|
app.use(cors(buildCorsOptions()))
|
||||||
|
app.use(cookieParser())
|
||||||
|
app.use(express.json({ limit: '50mb' }))
|
||||||
|
|
||||||
|
const authLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 60,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const apiLimiter = rateLimit({
|
||||||
|
windowMs: 1 * 60 * 1000,
|
||||||
|
max: 300,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const publicCollaborationLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 30,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false
|
||||||
|
})
|
||||||
|
|
||||||
|
app.use('/api/auth', authLimiter)
|
||||||
|
app.use('/api/collaboration/invite-details', publicCollaborationLimiter)
|
||||||
|
app.use('/api/collaboration/share-pull', publicCollaborationLimiter)
|
||||||
|
app.use('/api', apiLimiter)
|
||||||
|
|
||||||
|
app.use('/api/auth', authRouter)
|
||||||
|
app.use('/api/logbooks', logbooksRouter)
|
||||||
|
app.use('/api/sync', syncRouter)
|
||||||
|
app.use('/api/collaboration', collaborationRouter)
|
||||||
|
app.use('/api/sign', signRouter)
|
||||||
|
app.use('/api/push', pushRouter)
|
||||||
|
app.use('/api/weather', weatherRouter)
|
||||||
|
app.use('/api/feedback', feedbackRouter)
|
||||||
|
|
||||||
|
app.get('/api/health', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
await prisma.$queryRaw`SELECT 1`
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
database: 'connected',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
service: 'Kapteins Daagbok Backend'
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({
|
||||||
|
status: 'error',
|
||||||
|
database: 'disconnected',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
service: 'Kapteins Daagbok Backend'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return app
|
||||||
|
}
|
||||||