Compare commits
120 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 85e641ed39 | |||
| 9bf59280b2 | |||
| aee8f4f3db | |||
| 2b029a26f0 | |||
| 2156aa4bbd | |||
| 5eb4543255 | |||
| fb9bb6754c | |||
| 959afd5a63 | |||
| e3ea45f717 | |||
| 8f57b6ff22 | |||
| 60e1b714b7 | |||
| 1e203bfec1 | |||
| 11420685cf | |||
| c674aac344 | |||
| 9c91a0f1fc | |||
| 2bcbbba626 | |||
| b1500f8361 | |||
| bc7512003e | |||
| eaf126b584 | |||
| a9c712be45 | |||
| b0195601de | |||
| c2b58baa6e | |||
| a85d6e42fc | |||
| 53da4a14a0 | |||
| 2453134c51 | |||
| 671cb2dd9a | |||
| 1d511e0f8c | |||
| 18a68367bc | |||
| 90518372d8 | |||
| 9d22cb61c7 | |||
| bb501ba644 | |||
| f51f088f1e | |||
| 3d2918e0fe | |||
| c5a9b39057 | |||
| 2c8a858c89 | |||
| ee94a5be10 | |||
| 08798dc9b2 | |||
| ddeb69437a | |||
| cdcef2e106 | |||
| 847c73fda9 | |||
| ec11dd8d2b | |||
| 182ea497d8 | |||
| 837bcfe287 | |||
| d261a1e7ca | |||
| 2ebc3e8a44 | |||
| 047a5b1bdb | |||
| 7a7e9d5d28 | |||
| 39cbe707c7 | |||
| bb6e7f5c32 | |||
| ca0daa8f2a | |||
| 2304f95ac1 | |||
| 98c0ed81d4 | |||
| 3504ec97cc | |||
| 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 | |||
| 77a7072b77 | |||
| bd1edd89f3 | |||
| ffe6b19818 | |||
| eb1f87f57e | |||
| 13cb03646b | |||
| 1bc0d7fb2a | |||
| 5f3d76b30f | |||
| b48545e943 | |||
| 3749f87c1d | |||
| 2e656dc6b2 | |||
| 484ed66b7b | |||
| 49d77f08a2 | |||
| 951b5b3f1c | |||
| abb708c3d0 | |||
| cc87b0f8e6 | |||
| 58984594b0 | |||
| 61675e1085 | |||
| 2082218f78 | |||
| 5882edcbdf | |||
| b7a47a1d90 | |||
| 48c408302f | |||
| 2b5c5d4a36 | |||
| 7cf04b3357 | |||
| bbd4281dcb |
+22
-5
@@ -1,13 +1,30 @@
|
||||
OpenWeatherMapAPIKey=<owm_api_key>
|
||||
|
||||
# DeepL API (for scripts/translate-locales.mjs and scripts/translate-flyer.mjs)
|
||||
# Free plan keys use api-free.deepl.com automatically (suffix :fx)
|
||||
DeepLAPIKey=
|
||||
|
||||
# Passkey configuration (WebAuthn Relying Party ID and Origin)
|
||||
# For local dev: localhost and http://localhost
|
||||
# For production: e.g. kapteins-daagbok.eu and https://kapteins-daagbok.eu
|
||||
# For local dev: use localhost (NOT 127.0.0.1 — browsers reject IP addresses for Passkeys)
|
||||
# Production (kapteins-daagbok.eu):
|
||||
# RP_ID=kapteins-daagbok.eu
|
||||
# ORIGIN=https://kapteins-daagbok.eu
|
||||
RP_ID=localhost
|
||||
# Must match the frontend URL (Vite dev: http://localhost:5173; Docker: http://localhost)
|
||||
# Must match the frontend URL exactly (Vite dev: http://localhost:5173; Docker: http://localhost)
|
||||
ORIGIN=http://localhost:5173
|
||||
# Optional: comma-separated CORS origins (defaults to ORIGIN; dev also allows 127.0.0.1:5173)
|
||||
# CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
||||
|
||||
# Behind reverse proxy — see docs/deployment/npm-security.md
|
||||
# Docker Compose (NPM → frontend nginx → backend): TRUST_PROXY=1
|
||||
# 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)
|
||||
# Generate: openssl rand -base64 48
|
||||
|
||||
@@ -11,3 +11,5 @@ server/dist/
|
||||
.env.local
|
||||
.env.*.local
|
||||
*.log
|
||||
|
||||
userfeedback/
|
||||
|
||||
@@ -219,13 +219,19 @@ cd server && npx prisma db push && cd ..
|
||||
| Health Check | http://localhost:5000/api/health |
|
||||
| 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
|
||||
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)
|
||||
|
||||
@@ -237,11 +243,12 @@ Gesamten Stack lokal bauen und starten:
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
./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.
|
||||
|
||||
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
|
||||
|
||||
| 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/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 |
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
<link rel="canonical" href="https://kapteins-daagbok.eu/" />
|
||||
<link rel="alternate" hreflang="de" href="https://kapteins-daagbok.eu/?lng=de" />
|
||||
<link rel="alternate" hreflang="en" href="https://kapteins-daagbok.eu/?lng=en" />
|
||||
<link rel="alternate" hreflang="da" href="https://kapteins-daagbok.eu/?lng=da" />
|
||||
<link rel="alternate" hreflang="sv" href="https://kapteins-daagbok.eu/?lng=sv" />
|
||||
<link rel="alternate" hreflang="nb" href="https://kapteins-daagbok.eu/?lng=nb" />
|
||||
<link rel="alternate" hreflang="x-default" href="https://kapteins-daagbok.eu/" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
@@ -19,6 +22,7 @@
|
||||
<meta name="apple-mobile-web-app-title" content="Daagbok" />
|
||||
<meta name="theme-color" content="#0b0c10" />
|
||||
<script src="/appearance-bootstrap.js"></script>
|
||||
<script src="/bootstrap-watchdog.js"></script>
|
||||
<link rel="apple-touch-icon" href="/logo.png" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Kapteins Daagbok" />
|
||||
|
||||
+23
-3
@@ -3,15 +3,32 @@ server {
|
||||
server_name localhost;
|
||||
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
|
||||
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest)$ {
|
||||
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ {
|
||||
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 {
|
||||
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 / {
|
||||
@@ -26,6 +43,9 @@ server {
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+12
-30
@@ -17,6 +17,7 @@
|
||||
"jspdf": "^4.2.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^1.16.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-i18next": "^17.0.8"
|
||||
@@ -25,6 +26,7 @@
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^24.12.3",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
@@ -34,7 +36,6 @@
|
||||
"globals": "^17.6.0",
|
||||
"happy-dom": "^20.9.0",
|
||||
"playwright": "^1.51.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.59.2",
|
||||
"vite": "^6.3.5",
|
||||
@@ -2970,6 +2971,16 @@
|
||||
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/qrcode": {
|
||||
"version": "1.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/raf": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
|
||||
@@ -3461,7 +3472,6 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -3471,7 +3481,6 @@
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
@@ -3777,7 +3786,6 @@
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@@ -3855,7 +3863,6 @@
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
@@ -3867,7 +3874,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
@@ -3880,7 +3886,6 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
@@ -4051,7 +4056,6 @@
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -4140,7 +4144,6 @@
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
@@ -4195,7 +4198,6 @@
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/entities": {
|
||||
@@ -4948,7 +4950,6 @@
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
@@ -5498,7 +5499,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -6208,7 +6208,6 @@
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@@ -6231,7 +6230,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -6376,7 +6374,6 @@
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
@@ -6458,7 +6455,6 @@
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
@@ -6653,7 +6649,6 @@
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -6673,7 +6668,6 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
@@ -6845,7 +6839,6 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/set-function-length": {
|
||||
@@ -7113,7 +7106,6 @@
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
@@ -7230,7 +7222,6 @@
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
@@ -8067,7 +8058,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/which-typed-array": {
|
||||
@@ -8343,7 +8333,6 @@
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
@@ -8380,7 +8369,6 @@
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
@@ -8394,7 +8382,6 @@
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
@@ -8417,7 +8404,6 @@
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
@@ -8431,7 +8417,6 @@
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
@@ -8445,7 +8430,6 @@
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
@@ -8458,7 +8442,6 @@
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
@@ -8474,7 +8457,6 @@
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
|
||||
+9
-3
@@ -10,7 +10,12 @@
|
||||
"test": "vitest run",
|
||||
"preview": "vite preview",
|
||||
"generate:flyer": "node ../scripts/generate-beta-flyer.mjs",
|
||||
"generate:flyer:setup": "playwright install chromium"
|
||||
"generate:flyer:png": "node ../scripts/generate-beta-flyer.mjs --png",
|
||||
"generate:flyer:all": "node ../scripts/generate-beta-flyer.mjs --all",
|
||||
"generate:flyer:setup": "playwright install chromium",
|
||||
"translate:locales": "node ../scripts/translate-locales.mjs",
|
||||
"translate:flyer": "node ../scripts/translate-flyer.mjs",
|
||||
"validate:i18n": "node ../scripts/validate-i18n-keys.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
@@ -24,12 +29,14 @@
|
||||
"lucide-react": "^1.16.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-i18next": "^17.0.8"
|
||||
"react-i18next": "^17.0.8",
|
||||
"qrcode": "^1.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^24.12.3",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
@@ -39,7 +46,6 @@
|
||||
"globals": "^17.6.0",
|
||||
"happy-dom": "^20.9.0",
|
||||
"playwright": "^1.51.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.59.2",
|
||||
"vite": "^6.3.5",
|
||||
|
||||
Vendored
+221
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Boot watchdog for production PWAs.
|
||||
* Recovers from white/black screens when stale HTML points to missing JS chunks.
|
||||
* Does not clear caches automatically while offline to protect unsynced data.
|
||||
*/
|
||||
(function () {
|
||||
if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') {
|
||||
return
|
||||
}
|
||||
|
||||
var BOOT_TIMEOUT_MS = 12000
|
||||
var ATTEMPT_WINDOW_MS = 120000
|
||||
var ATTEMPT_COUNT_KEY = 'pwa_boot_watchdog_attempt_count'
|
||||
var ATTEMPT_LAST_KEY = 'pwa_boot_watchdog_attempt_last_ts'
|
||||
var PENDING_EVENTS_KEY = 'pwa_boot_pending_events'
|
||||
var MAX_PENDING_EVENTS = 12
|
||||
|
||||
function enqueueEvent(name, props) {
|
||||
try {
|
||||
var current = JSON.parse(sessionStorage.getItem(PENDING_EVENTS_KEY) || '[]')
|
||||
if (!Array.isArray(current)) current = []
|
||||
current.push({ name: name, props: props, ts: Date.now() })
|
||||
if (current.length > MAX_PENDING_EVENTS) {
|
||||
current = current.slice(current.length - MAX_PENDING_EVENTS)
|
||||
}
|
||||
sessionStorage.setItem(PENDING_EVENTS_KEY, JSON.stringify(current))
|
||||
} catch (_) {
|
||||
/* ignore analytics queue errors */
|
||||
}
|
||||
}
|
||||
|
||||
function emit(name, props) {
|
||||
if (typeof window.plausible === 'function') {
|
||||
if (props && Object.keys(props).length > 0) {
|
||||
window.plausible(name, { props: props })
|
||||
} else {
|
||||
window.plausible(name)
|
||||
}
|
||||
return
|
||||
}
|
||||
enqueueEvent(name, props)
|
||||
}
|
||||
|
||||
function hasBootstrapped() {
|
||||
return window.__KDB_APP_BOOTSTRAPPED === true
|
||||
}
|
||||
|
||||
function resetAttempts() {
|
||||
try {
|
||||
sessionStorage.removeItem(ATTEMPT_COUNT_KEY)
|
||||
sessionStorage.removeItem(ATTEMPT_LAST_KEY)
|
||||
} catch (_) {
|
||||
/* ignore storage errors */
|
||||
}
|
||||
}
|
||||
|
||||
function nextAttempt() {
|
||||
try {
|
||||
var now = Date.now()
|
||||
var last = Number(sessionStorage.getItem(ATTEMPT_LAST_KEY) || '0')
|
||||
var count = Number(sessionStorage.getItem(ATTEMPT_COUNT_KEY) || '0')
|
||||
if (now - last > ATTEMPT_WINDOW_MS) {
|
||||
count = 0
|
||||
}
|
||||
count += 1
|
||||
sessionStorage.setItem(ATTEMPT_COUNT_KEY, String(count))
|
||||
sessionStorage.setItem(ATTEMPT_LAST_KEY, String(now))
|
||||
return count
|
||||
} catch (_) {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
function createRecoveryUrl(reason) {
|
||||
try {
|
||||
var url = new URL(location.href)
|
||||
url.searchParams.set('boot_recover', reason)
|
||||
url.searchParams.set('_', String(Date.now()))
|
||||
return url.toString()
|
||||
} catch (_) {
|
||||
return location.href
|
||||
}
|
||||
}
|
||||
|
||||
async function clearServiceWorkerCaches() {
|
||||
if ('serviceWorker' in navigator) {
|
||||
try {
|
||||
var registrations = await navigator.serviceWorker.getRegistrations()
|
||||
await Promise.all(
|
||||
registrations.map(function (registration) {
|
||||
return registration.unregister()
|
||||
})
|
||||
)
|
||||
} catch (_) {
|
||||
/* ignore SW cleanup errors */
|
||||
}
|
||||
}
|
||||
if ('caches' in window) {
|
||||
try {
|
||||
var keys = await caches.keys()
|
||||
await Promise.all(
|
||||
keys.map(function (key) {
|
||||
return caches.delete(key)
|
||||
})
|
||||
)
|
||||
} catch (_) {
|
||||
/* ignore cache cleanup errors */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderFallback(isOffline) {
|
||||
var root = document.getElementById('root')
|
||||
if (!root) return
|
||||
|
||||
root.innerHTML =
|
||||
'<div class="auth-screen">' +
|
||||
'<div class="auth-card glass" role="alert" style="max-width:460px">' +
|
||||
'<h2 style="margin-top:0">Kapteins Daagbok</h2>' +
|
||||
'<p style="color:var(--app-text-muted);line-height:1.5;margin-bottom:8px">' +
|
||||
(isOffline
|
||||
? 'Die App konnte offline nicht sauber starten. Deine lokalen, nicht synchronisierten Daten bleiben erhalten.'
|
||||
: 'Die App konnte nicht sauber starten. Deine lokalen, nicht synchronisierten Daten bleiben erhalten.') +
|
||||
'</p>' +
|
||||
'<p style="color:var(--app-text-muted);line-height:1.5;margin-top:0">' +
|
||||
(isOffline
|
||||
? 'Bitte neu laden. Wenn wieder Netz verfügbar ist, kann die App-Engine automatisch repariert werden.'
|
||||
: 'Du kannst jetzt eine App-Reparatur ausfuehren, ohne IndexedDB-Logbuchdaten zu loeschen.') +
|
||||
'</p>' +
|
||||
'<button type="button" class="btn primary" id="boot-reload-btn" style="width:100%">' +
|
||||
'Neu laden' +
|
||||
'</button>' +
|
||||
(!isOffline
|
||||
? '<button type="button" class="btn secondary" id="boot-repair-btn" style="width:100%;margin-top:12px">' +
|
||||
'App-Reparatur (Cache + Service Worker)' +
|
||||
'</button>'
|
||||
: '') +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
|
||||
var reloadBtn = document.getElementById('boot-reload-btn')
|
||||
if (reloadBtn) {
|
||||
reloadBtn.addEventListener('click', function () {
|
||||
location.replace(createRecoveryUrl('retry'))
|
||||
})
|
||||
}
|
||||
|
||||
var repairBtn = document.getElementById('boot-repair-btn')
|
||||
if (repairBtn) {
|
||||
repairBtn.addEventListener('click', function () {
|
||||
emit('PWA Boot Watchdog Manual Repair', {
|
||||
attempt: Number(sessionStorage.getItem(ATTEMPT_COUNT_KEY) || '0'),
|
||||
online: navigator.onLine
|
||||
})
|
||||
Promise.resolve()
|
||||
.then(clearServiceWorkerCaches)
|
||||
.finally(function () {
|
||||
resetAttempts()
|
||||
location.replace(createRecoveryUrl('manual-hard-recovery'))
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function runWatchdog() {
|
||||
window.setTimeout(function () {
|
||||
if (hasBootstrapped()) {
|
||||
resetAttempts()
|
||||
return
|
||||
}
|
||||
|
||||
var attempt = nextAttempt()
|
||||
var online = navigator.onLine
|
||||
|
||||
if (attempt === 1) {
|
||||
emit('PWA Boot Watchdog Soft', {
|
||||
attempt: attempt,
|
||||
online: online,
|
||||
reason: online ? 'soft-reload' : 'offline-retry'
|
||||
})
|
||||
Promise.resolve()
|
||||
.then(function () {
|
||||
if ('serviceWorker' in navigator && navigator.serviceWorker.getRegistration) {
|
||||
return navigator.serviceWorker.getRegistration().then(function (registration) {
|
||||
if (registration) {
|
||||
return registration.update().catch(function () {})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
.finally(function () {
|
||||
location.replace(createRecoveryUrl(online ? 'soft-reload' : 'offline-retry'))
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (attempt === 2 && online) {
|
||||
emit('PWA Boot Watchdog Hard', {
|
||||
attempt: attempt,
|
||||
online: online,
|
||||
reason: 'hard-recovery'
|
||||
})
|
||||
Promise.resolve()
|
||||
.then(clearServiceWorkerCaches)
|
||||
.finally(function () {
|
||||
location.replace(createRecoveryUrl('hard-recovery'))
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
emit('PWA Boot Watchdog Fallback', {
|
||||
attempt: attempt,
|
||||
online: online,
|
||||
reason: online ? 'retries-exhausted' : 'offline-retries-exhausted'
|
||||
})
|
||||
renderFallback(!online)
|
||||
}, BOOT_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
runWatchdog()
|
||||
})()
|
||||
+1221
-30
File diff suppressed because it is too large
Load Diff
+118
-23
@@ -1,11 +1,14 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import './App.css'
|
||||
import { DialogProvider } from './components/ModalDialog.tsx'
|
||||
import AuthOnboarding from './components/AuthOnboarding.tsx'
|
||||
import UserProfilePage from './components/UserProfilePage.tsx'
|
||||
import LogbookDashboard from './components/LogbookDashboard.tsx'
|
||||
import VesselForm from './components/VesselForm.tsx'
|
||||
import CrewForm from './components/CrewForm.tsx'
|
||||
import LogbookVesselPicker from './components/LogbookVesselPicker.tsx'
|
||||
import LogbookCrewPicker from './components/LogbookCrewPicker.tsx'
|
||||
import { migrateLegacyCrewToPoolIfNeeded } from './services/crewMigration.js'
|
||||
import { migrateLegacyYachtsToPoolIfNeeded } from './services/vesselMigration.js'
|
||||
import { syncVesselPool } from './services/vesselPoolSync.js'
|
||||
import { syncPersonPool } from './services/personPoolSync.js'
|
||||
// Compass Deviation Table — für Freizeit-Skipper vorerst deaktiviert (Komponente bleibt erhalten)
|
||||
// import DeviationForm from './components/DeviationForm.tsx'
|
||||
import LogEntriesList from './components/LogEntriesList.tsx'
|
||||
@@ -45,13 +48,17 @@ import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, Languages, BarChart2 } from 'lucide-react'
|
||||
import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
|
||||
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
|
||||
import ProfileHeaderButton from './components/ProfileHeaderButton.tsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cycleAppLanguage } from './utils/i18nLanguages.js'
|
||||
import {
|
||||
resolveTourLogbookContext,
|
||||
seedDemoLogbookIfNeeded
|
||||
} from './services/demoLogbook.js'
|
||||
import { fetchLogbooks, parseCollaborationRole } from './services/logbook.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'
|
||||
|
||||
@@ -70,6 +77,7 @@ function App() {
|
||||
const [isSyncing, setIsSyncing] = useState(false)
|
||||
const [isAcceptingInvite, setIsAcceptingInvite] = useState(false)
|
||||
const [showUserProfile, setShowUserProfile] = useState(false)
|
||||
const [storagePersistHint, setStoragePersistHint] = useState(false)
|
||||
const tourLogbookRef = useRef<{ id: string; title: string } | null>(null)
|
||||
const activeLogbookRef = useRef<{ id: string | null; title: string | null }>({
|
||||
id: activeLogbookId,
|
||||
@@ -157,6 +165,8 @@ function App() {
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId) return
|
||||
void syncAppearancePrefs(userId)
|
||||
void migrateLegacyCrewToPoolIfNeeded().then(() => syncPersonPool())
|
||||
void migrateLegacyYachtsToPoolIfNeeded().then(() => syncVesselPool())
|
||||
}, [isAuthenticated])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -427,10 +437,19 @@ function App() {
|
||||
return () => navigator.serviceWorker.removeEventListener('message', onSwMessage)
|
||||
}, [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 () => {
|
||||
setIsAuthenticated(true)
|
||||
trackPlausibleEvent(PlausibleEvents.LOGGED_IN)
|
||||
void ensurePushSubscriptionIfEnabled()
|
||||
void requestPersistentStorage()
|
||||
|
||||
try {
|
||||
const demo = await seedDemoLogbookIfNeeded()
|
||||
@@ -497,8 +516,7 @@ function App() {
|
||||
}
|
||||
|
||||
const toggleLanguage = () => {
|
||||
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
|
||||
i18n.changeLanguage(nextLang)
|
||||
cycleAppLanguage(i18n)
|
||||
}
|
||||
|
||||
const handleExitDemo = () => {
|
||||
@@ -558,22 +576,27 @@ function App() {
|
||||
const isLogbookOwner =
|
||||
activeAccessRole === 'OWNER' || activeLogbookRecord?.isShared !== 1
|
||||
|
||||
if (showUserProfile) {
|
||||
return (
|
||||
<div style={{ display: 'contents' }}>
|
||||
{pwaInstallBanner}
|
||||
<UserProfilePage
|
||||
onBack={() => setShowUserProfile(false)}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!activeLogbookId) {
|
||||
return (
|
||||
<div style={{ display: 'contents' }}>
|
||||
{pwaInstallBanner}
|
||||
{showUserProfile ? (
|
||||
<UserProfilePage
|
||||
onBack={() => setShowUserProfile(false)}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
) : (
|
||||
<LogbookDashboard
|
||||
onSelectLogbook={selectLogbook}
|
||||
onLogout={handleLogout}
|
||||
onOpenProfile={() => setShowUserProfile(true)}
|
||||
/>
|
||||
)}
|
||||
<LogbookDashboard
|
||||
onSelectLogbook={selectLogbook}
|
||||
onLogout={handleLogout}
|
||||
onOpenProfile={() => setShowUserProfile(true)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -601,7 +624,7 @@ function App() {
|
||||
<p className="app-subtitle">
|
||||
{activeAccessRole && activeAccessRole !== 'OWNER'
|
||||
? t('dashboard.section_shared_hint')
|
||||
: `${t('app.name')} / ${activeLogbookId?.substring(0, 8)}...`}
|
||||
: t('app.tagline')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -623,6 +646,8 @@ function App() {
|
||||
<Languages size={18} />
|
||||
</button>
|
||||
|
||||
<ProfileHeaderButton onClick={() => setShowUserProfile(true)} />
|
||||
|
||||
<DisclaimerHeaderButton />
|
||||
|
||||
<FeedbackHeaderButton
|
||||
@@ -639,10 +664,28 @@ function App() {
|
||||
</div>
|
||||
</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 */}
|
||||
<div className="app-body">
|
||||
{/* Navigation Sidebar */}
|
||||
<aside className="app-sidebar">
|
||||
<aside className="app-sidebar" aria-label={t('nav.dashboard')}>
|
||||
<button
|
||||
className={`sidebar-btn ${activeTab === 'logs' ? 'active' : ''}`}
|
||||
onClick={() => void handleTabChange('logs')}
|
||||
@@ -664,7 +707,7 @@ function App() {
|
||||
<button
|
||||
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
|
||||
onClick={() => void handleTabChange('crew')}
|
||||
data-tour="nav-crew"
|
||||
data-tour="nav-logbook-crew"
|
||||
>
|
||||
<Users size={18} />
|
||||
{t('nav.crew')}
|
||||
@@ -711,14 +754,19 @@ function App() {
|
||||
)}
|
||||
|
||||
{activeTab === 'vessel' && (
|
||||
<VesselForm logbookId={activeLogbookId} readOnly={logbookReadOnly || !isLogbookOwner} />
|
||||
<LogbookVesselPicker
|
||||
logbookId={activeLogbookId}
|
||||
readOnly={logbookReadOnly || !isLogbookOwner}
|
||||
selectionOnly={!isLogbookOwner && activeLogbookRecord?.isShared === 1}
|
||||
onOpenProfile={isLogbookOwner ? () => setShowUserProfile(true) : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'crew' && (
|
||||
<CrewForm
|
||||
<LogbookCrewPicker
|
||||
logbookId={activeLogbookId}
|
||||
readOnly={logbookReadOnly}
|
||||
skipperReadOnly={!isLogbookOwner}
|
||||
selectionOnly={!isLogbookOwner && activeLogbookRecord?.isShared === 1}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -739,6 +787,53 @@ function App() {
|
||||
/>
|
||||
)}
|
||||
</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-logbook-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>
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
registerUser,
|
||||
loginUser,
|
||||
completeLoginWithRecovery,
|
||||
setLocalPin,
|
||||
hasLocalPin,
|
||||
decryptWithLocalPin,
|
||||
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
|
||||
import {
|
||||
registerUser,
|
||||
loginUser,
|
||||
completeLoginWithRecovery,
|
||||
setLocalPin,
|
||||
hasLocalPin,
|
||||
decryptWithLocalPin,
|
||||
getActiveMasterKey,
|
||||
getKnownUsernames,
|
||||
forgetUsername
|
||||
forgetUsername,
|
||||
hasUnlockedLocalSession,
|
||||
logoutUser
|
||||
} from '../services/auth.js'
|
||||
import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react'
|
||||
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
|
||||
import DisclaimerModal from './DisclaimerModal.tsx'
|
||||
import BetaBadge from './BetaBadge.tsx'
|
||||
import {
|
||||
isPasskeyCompatibleLocation,
|
||||
localizeWebAuthnError,
|
||||
toPasskeyCompatibleUrl
|
||||
} from '../utils/passkeyHost.ts'
|
||||
|
||||
interface AuthOnboardingProps {
|
||||
onAuthenticated: () => void
|
||||
@@ -53,6 +61,16 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
||||
const [showDisclaimer, setShowDisclaimer] = useState(false)
|
||||
const [showHelp, setShowHelp] = useState(false)
|
||||
|
||||
const passkeyHostOk = isPasskeyCompatibleLocation()
|
||||
const passkeyCompatibleUrl = passkeyHostOk ? null : toPasskeyCompatibleUrl(window.location.href)
|
||||
|
||||
const formatAuthError = (message: string) =>
|
||||
localizeWebAuthnError(message, {
|
||||
invalidHost: t('auth.error_invalid_host'),
|
||||
cancelled: t('auth.error_passkey_cancelled'),
|
||||
invalidRpId: t('auth.error_invalid_rp_id')
|
||||
})
|
||||
|
||||
const finishAuth = () => {
|
||||
if (isNewRegistration) {
|
||||
setShowDisclaimer(true)
|
||||
@@ -80,7 +98,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
||||
setRecoveryPhrase(result.recoveryPhrase)
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Registration failed')
|
||||
setError(formatAuthError(err.message || 'Registration failed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -120,7 +138,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Login failed')
|
||||
setError(formatAuthError(err.message || 'Login failed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -184,19 +202,33 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
||||
|
||||
const handlePinLoginSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!pinLoginInput.trim()) return
|
||||
if (!pinLoginInput.trim() || loading) return
|
||||
|
||||
const resolvedUser =
|
||||
username.trim() ||
|
||||
encryptedPayloads?.username ||
|
||||
localStorage.getItem('active_username') ||
|
||||
''
|
||||
if (!resolvedUser) {
|
||||
setError(t('auth.error_session_incomplete'))
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const resolvedUser = username.trim() || encryptedPayloads?.username
|
||||
const key = await decryptWithLocalPin(pinLoginInput.trim(), resolvedUser)
|
||||
if (key) {
|
||||
onAuthenticated()
|
||||
} else {
|
||||
if (!key) {
|
||||
setError(t('auth.error_incorrect_pin'))
|
||||
return
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (!hasUnlockedLocalSession()) {
|
||||
setError(t('auth.error_session_incomplete'))
|
||||
return
|
||||
}
|
||||
setShowPinLogin(false)
|
||||
onAuthenticated()
|
||||
} catch {
|
||||
setError(t('auth.error_incorrect_pin'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
@@ -209,8 +241,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
||||
}
|
||||
|
||||
const toggleLanguage = () => {
|
||||
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
|
||||
i18n.changeLanguage(nextLang)
|
||||
cycleAppLanguage(i18n)
|
||||
}
|
||||
|
||||
const copyToClipboard = () => {
|
||||
@@ -361,6 +392,24 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
||||
>
|
||||
{t('auth.use_recovery_instead')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
setShowPinLogin(false)
|
||||
setPinLoginInput('')
|
||||
setEncryptedPayloads(null)
|
||||
setError(null)
|
||||
await logoutUser()
|
||||
})()
|
||||
}}
|
||||
disabled={loading}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{t('auth.back')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -445,12 +494,21 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
||||
</div>
|
||||
|
||||
<div className="auth-form" style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||
{!passkeyHostOk && passkeyCompatibleUrl && (
|
||||
<div className="auth-error" role="alert">
|
||||
<p style={{ margin: '0 0 8px' }}>{t('auth.error_invalid_host')}</p>
|
||||
<a href={passkeyCompatibleUrl} className="btn secondary" style={{ display: 'inline-block', textDecoration: 'none' }}>
|
||||
{t('auth.use_localhost_link')}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Prominent Login button */}
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
onClick={() => handleLogin()}
|
||||
disabled={loading}
|
||||
disabled={loading || !passkeyHostOk}
|
||||
style={{ width: '100%', padding: '16px' }}
|
||||
>
|
||||
{loading
|
||||
@@ -583,7 +641,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
||||
<button
|
||||
type="submit"
|
||||
className="btn secondary"
|
||||
disabled={loading || !username.trim()}
|
||||
disabled={loading || !username.trim() || !passkeyHostOk}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{t('auth.register')}
|
||||
@@ -596,7 +654,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
||||
<div className="auth-footer">
|
||||
<button type="button" className="btn-icon-text" onClick={toggleLanguage}>
|
||||
<Languages size={18} />
|
||||
{i18n.language.startsWith('de') ? 'English' : 'Deutsch'}
|
||||
{t(`languages.${getNextLanguage(i18n.language)}`)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getLogbookKey } from '../services/logbookKeys.js'
|
||||
import { encryptJson, decryptJson } from '../services/crypto.js'
|
||||
import { syncLogbook } from '../services/sync.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { MAX_POOL_CREW_MEMBERS } from '../types/person.js'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import { Users, User, Plus, Trash2, Edit2, Save, X, Check, Camera } from 'lucide-react'
|
||||
|
||||
@@ -603,7 +604,7 @@ export default function CrewForm({
|
||||
<Users size={24} className="form-icon" />
|
||||
<h2>{t('crew.crew_section')}</h2>
|
||||
</div>
|
||||
{!readOnly && crewList.length < 5 && !showMemberForm && (
|
||||
{!readOnly && crewList.length < MAX_POOL_CREW_MEMBERS && !showMemberForm && (
|
||||
<button className="btn primary" onClick={openAddMember} style={{ width: 'auto', padding: '8px 16px' }}>
|
||||
<Plus size={16} />
|
||||
{t('crew.add_crew')}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import VesselForm from './VesselForm.tsx'
|
||||
import CrewForm from './CrewForm.tsx'
|
||||
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
|
||||
import LogbookVesselPicker from './LogbookVesselPicker.tsx'
|
||||
import LogbookCrewPicker from './LogbookCrewPicker.tsx'
|
||||
import type { LogbookCrewSelectionData } from '../types/person.js'
|
||||
import { personToSnapshot } from '../utils/personSnapshots.js'
|
||||
import LogEntriesList from './LogEntriesList.tsx'
|
||||
import { Ship, Users, FileText, Lock, Globe, ChevronLeft, UserPlus } from 'lucide-react'
|
||||
import { buildPublicDemoFixture, type PublicDemoFixture } from '../services/demoLogbookData.js'
|
||||
import type { VesselData } from '../types/vessel.js'
|
||||
import type { LogbookVesselSelectionData } from '../types/vessel.js'
|
||||
import { useAppTour, type AppTab } from '../context/AppTourContext.tsx'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
|
||||
@@ -48,11 +53,32 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
||||
}, [registerNavigation, registerDemoTourContext, startTour, fixture.firstEntryId])
|
||||
|
||||
const toggleLanguage = () => {
|
||||
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
|
||||
i18n.changeLanguage(nextLang)
|
||||
cycleAppLanguage(i18n)
|
||||
}
|
||||
|
||||
const { title, yacht, crews, entries, gpsTracks, photos, firstEntryId } = fixture
|
||||
const {
|
||||
title,
|
||||
yacht,
|
||||
vesselPool,
|
||||
logbookVesselSelection,
|
||||
personPool,
|
||||
logbookCrewSelection,
|
||||
entries,
|
||||
gpsTracks,
|
||||
photos,
|
||||
firstEntryId
|
||||
} = fixture
|
||||
|
||||
const demoSelection: LogbookCrewSelectionData = {
|
||||
activeSkipperId: logbookCrewSelection.activeSkipperId,
|
||||
activeCrewIds: logbookCrewSelection.activeCrewIds,
|
||||
snapshotsById: Object.fromEntries(
|
||||
Object.entries(logbookCrewSelection.snapshotsById).map(([id, snap]) => [
|
||||
id,
|
||||
personToSnapshot(id, snap)
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-layout">
|
||||
@@ -87,7 +113,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
||||
</button>
|
||||
<button className="btn secondary" onClick={toggleLanguage} style={{ width: 'auto', padding: '6px 12px', fontSize: '13px' }}>
|
||||
<Globe size={14} style={{ marginRight: '4px' }} />
|
||||
{i18n.language.startsWith('de') ? 'English' : 'Deutsch'}
|
||||
{t(`languages.${getNextLanguage(i18n.language)}`)}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -115,7 +141,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
||||
<button
|
||||
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('crew')}
|
||||
data-tour="nav-crew"
|
||||
data-tour="nav-logbook-crew"
|
||||
>
|
||||
<Users size={18} />
|
||||
{t('nav.crew')}
|
||||
@@ -138,11 +164,24 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
||||
)}
|
||||
|
||||
{activeTab === 'vessel' && (
|
||||
<VesselForm logbookId="demo" readOnly={true} preloadedData={yacht} />
|
||||
<LogbookVesselPicker
|
||||
logbookId="demo"
|
||||
readOnly={true}
|
||||
preloadedPool={vesselPool.map((v) => ({
|
||||
payloadId: v.payloadId,
|
||||
data: v.data as VesselData
|
||||
}))}
|
||||
preloadedSelection={logbookVesselSelection as LogbookVesselSelectionData}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'crew' && (
|
||||
<CrewForm logbookId="demo" readOnly={true} preloadedData={crews} />
|
||||
<LogbookCrewPicker
|
||||
logbookId="demo"
|
||||
readOnly={true}
|
||||
preloadedPool={personPool}
|
||||
preloadedSelection={demoSelection}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,12 @@ export default function DisclaimerModal({ open, onClose }: DisclaimerModalProps)
|
||||
if (event.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
const prevOverflow = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
document.body.style.overflow = prevOverflow
|
||||
}
|
||||
}, [open, onClose])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Users } from 'lucide-react'
|
||||
import type { EntryCrewFields, PersonSnapshot } from '../types/person.js'
|
||||
import { loadPersonPool } from '../services/personPool.js'
|
||||
import { loadLogbookCrewSelection } from '../services/logbookCrewSelection.js'
|
||||
import { buildSnapshotsForSelection } from '../utils/personSnapshots.js'
|
||||
import type { PersonData } from '../types/person.js'
|
||||
|
||||
export interface EntryCrewSectionProps {
|
||||
logbookId: string
|
||||
readOnly?: boolean
|
||||
value: EntryCrewFields
|
||||
onChange: (next: EntryCrewFields) => void
|
||||
/** Demo: fixed pool */
|
||||
preloadedPool?: Map<string, PersonData>
|
||||
}
|
||||
|
||||
export default function EntryCrewSection({
|
||||
logbookId,
|
||||
readOnly = false,
|
||||
value,
|
||||
onChange,
|
||||
preloadedPool
|
||||
}: EntryCrewSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const [pool, setPool] = useState<Map<string, PersonData>>(preloadedPool ?? new Map())
|
||||
|
||||
useEffect(() => {
|
||||
if (preloadedPool) {
|
||||
setPool(preloadedPool)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
try {
|
||||
const people = await loadPersonPool()
|
||||
if (cancelled) return
|
||||
setPool(new Map(people.map((p) => [p.payloadId, p.data])))
|
||||
} catch {
|
||||
/* use snapshots only */
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [preloadedPool])
|
||||
|
||||
const displayPool = useMemo(() => {
|
||||
const merged = new Map(pool)
|
||||
for (const snap of Object.values(value.crewSnapshotsById)) {
|
||||
if (!merged.has(snap.id)) {
|
||||
merged.set(snap.id, {
|
||||
name: snap.name,
|
||||
address: snap.address,
|
||||
birthDate: snap.birthDate,
|
||||
phone: snap.phone,
|
||||
nationality: snap.nationality,
|
||||
passportNumber: snap.passportNumber,
|
||||
bloodType: snap.bloodType,
|
||||
allergies: snap.allergies,
|
||||
diseases: snap.diseases,
|
||||
role: snap.role,
|
||||
photo: snap.photo
|
||||
})
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}, [pool, value.crewSnapshotsById])
|
||||
|
||||
const skippers = [...displayPool.entries()].filter(([, d]) => d.role === 'skipper')
|
||||
const crewEntries = [...displayPool.entries()].filter(([, d]) => d.role === 'crew')
|
||||
|
||||
const applyChange = (skipperId: string | null, crewIds: string[]) => {
|
||||
const snapshots = buildSnapshotsForSelection(skipperId, crewIds, displayPool)
|
||||
onChange({
|
||||
selectedSkipperId: skipperId,
|
||||
selectedCrewIds: crewIds,
|
||||
crewSnapshotsById: snapshots
|
||||
})
|
||||
}
|
||||
|
||||
const toggleCrew = (id: string) => {
|
||||
if (readOnly) return
|
||||
const next = value.selectedCrewIds.includes(id)
|
||||
? value.selectedCrewIds.filter((x) => x !== id)
|
||||
: [...value.selectedCrewIds, id]
|
||||
applyChange(value.selectedSkipperId, next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="form-card" data-tour="entry-crew">
|
||||
<div className="form-header">
|
||||
<Users size={22} className="form-icon" />
|
||||
<h3>{t('entry_crew.title')}</h3>
|
||||
</div>
|
||||
<p className="help-text mb-3">{t('entry_crew.subtitle')}</p>
|
||||
|
||||
<div className="input-group mb-3">
|
||||
<label>{t('entry_crew.day_skipper')}</label>
|
||||
{skippers.length === 0 ? (
|
||||
<p className="help-text">{t('entry_crew.no_skipper')}</p>
|
||||
) : (
|
||||
<div className="crew-selection-list">
|
||||
{skippers.map(([id, data]) => (
|
||||
<label key={id} className="crew-selection-item">
|
||||
<input
|
||||
type="radio"
|
||||
name={`entry-skipper-${logbookId}`}
|
||||
checked={value.selectedSkipperId === id}
|
||||
onChange={() => !readOnly && applyChange(id, value.selectedCrewIds)}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<span>{data.name || t('logbook_crew.unnamed')}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('entry_crew.day_crew')}</label>
|
||||
{crewEntries.length === 0 ? (
|
||||
<p className="help-text">{t('entry_crew.no_crew')}</p>
|
||||
) : (
|
||||
<div className="crew-selection-list">
|
||||
{crewEntries.map(([id, data]) => (
|
||||
<label key={id} className="crew-selection-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value.selectedCrewIds.includes(id)}
|
||||
onChange={() => toggleCrew(id)}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<span>{data.name || t('logbook_crew.unnamed')}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export async function loadDefaultEntryCrewForNewDay(
|
||||
logbookId: string,
|
||||
previousEntry: Record<string, unknown> | null
|
||||
): Promise<EntryCrewFields> {
|
||||
if (previousEntry) {
|
||||
const selectedSkipperId =
|
||||
typeof previousEntry.selectedSkipperId === 'string' ? previousEntry.selectedSkipperId : null
|
||||
const selectedCrewIds = Array.isArray(previousEntry.selectedCrewIds)
|
||||
? previousEntry.selectedCrewIds.filter((id): id is string => typeof id === 'string')
|
||||
: []
|
||||
const crewSnapshotsById =
|
||||
previousEntry.crewSnapshotsById && typeof previousEntry.crewSnapshotsById === 'object'
|
||||
? (previousEntry.crewSnapshotsById as Record<string, PersonSnapshot>)
|
||||
: {}
|
||||
return { selectedSkipperId, selectedCrewIds, crewSnapshotsById }
|
||||
}
|
||||
|
||||
const selection = await loadLogbookCrewSelection(logbookId)
|
||||
return {
|
||||
selectedSkipperId: selection.activeSkipperId,
|
||||
selectedCrewIds: [...selection.activeCrewIds],
|
||||
crewSnapshotsById: { ...selection.snapshotsById }
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useId, useMemo } from 'react'
|
||||
import { joinTimeHHMM, splitTimeHHMM } from '../utils/logEntryPayload.js'
|
||||
import { preferNativeCameraPicker } from '../utils/captureVideoFrame.js'
|
||||
|
||||
const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'))
|
||||
const MINUTES = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'))
|
||||
@@ -18,7 +19,29 @@ export default function EventTimeInput24h({
|
||||
'aria-label': ariaLabel
|
||||
}: EventTimeInput24hProps) {
|
||||
const baseId = useId()
|
||||
const useNativePicker = preferNativeCameraPicker()
|
||||
const { hours, minutes } = useMemo(() => splitTimeHHMM(value), [value])
|
||||
const timeValue = useMemo(() => joinTimeHHMM(hours, minutes), [hours, minutes])
|
||||
|
||||
if (useNativePicker) {
|
||||
return (
|
||||
<div className="time-input-24h">
|
||||
<input
|
||||
id={baseId}
|
||||
type="time"
|
||||
step={60}
|
||||
className="input-text time-input-24h__native"
|
||||
value={timeValue}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value
|
||||
if (next) onChange(next.slice(0, 5))
|
||||
}}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="time-input-24h">
|
||||
|
||||
@@ -172,6 +172,7 @@ export default function FeedbackModal({
|
||||
<option value="general">{t('feedback.category_general')}</option>
|
||||
<option value="bug">{t('feedback.category_bug')}</option>
|
||||
<option value="feature">{t('feedback.category_feature')}</option>
|
||||
<option value="translation">{t('feedback.category_translation')}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
|
||||
import { Ship, LogIn, UserPlus, AlertTriangle, ShieldCheck, Languages, ArrowRight, KeyRound } from 'lucide-react'
|
||||
import {
|
||||
getActiveMasterKey,
|
||||
@@ -308,7 +309,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
||||
}
|
||||
|
||||
const toggleLanguage = () => {
|
||||
i18n.changeLanguage(i18n.language.startsWith('de') ? 'en' : 'de')
|
||||
cycleAppLanguage(i18n)
|
||||
}
|
||||
|
||||
if (recoveryPhrase) {
|
||||
@@ -511,7 +512,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
||||
<div className="auth-footer" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '16px', marginTop: '24px' }}>
|
||||
<button className="btn-icon-text" onClick={toggleLanguage}>
|
||||
<Languages size={18} />
|
||||
{i18n.language.startsWith('de') ? t('invitation.switch_language_en') : t('invitation.switch_language_de')}
|
||||
{t(`languages.${getNextLanguage(i18n.language)}`)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,11 +8,20 @@ import { syncLogbook } from '../services/sync.js'
|
||||
import { downloadCsv, shareCsv } from '../services/csvExport.js'
|
||||
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { getErrorMessage } from '../utils/errors.js'
|
||||
import { findTodayEntryId } from '../services/quickEventLog.js'
|
||||
import LogEntryEditor from './LogEntryEditor.tsx'
|
||||
import LiveLogView from './LiveLogView.tsx'
|
||||
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import { getSkipperSignStatus, type SkipperSignStatus } from '../utils/signatures.js'
|
||||
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2 } from 'lucide-react'
|
||||
import {
|
||||
buildEntryListCache,
|
||||
entryListItemFromLocal,
|
||||
putEntryRecord
|
||||
} from '../utils/entryListCache.js'
|
||||
import { forEachInBatches } from '../utils/yieldToMain.js'
|
||||
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2, Radio, List } from 'lucide-react'
|
||||
import {
|
||||
carryOverFromPreviousDay,
|
||||
compareTravelDaysChronological,
|
||||
@@ -36,6 +45,8 @@ interface LogEntriesListProps {
|
||||
highlightEntryId?: string | null
|
||||
}
|
||||
|
||||
type LogsViewMode = 'list' | 'live'
|
||||
|
||||
interface DecryptedEntryItem {
|
||||
id: string
|
||||
date: string
|
||||
@@ -75,6 +86,8 @@ export default function LogEntriesList({
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [viewMode, setViewMode] = useState<LogsViewMode>('list')
|
||||
const [returnToLiveAfterEditor, setReturnToLiveAfterEditor] = useState(false)
|
||||
const prevSelectedEntryIdRef = useRef<string | null | undefined>(undefined)
|
||||
|
||||
const loadEntries = useCallback(async () => {
|
||||
@@ -109,24 +122,34 @@ export default function LogEntriesList({
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
|
||||
const local = await db.entries.where({ logbookId }).toArray()
|
||||
|
||||
|
||||
const list: DecryptedEntryItem[] = []
|
||||
|
||||
const needsDecrypt: typeof local = []
|
||||
|
||||
for (const entry of local) {
|
||||
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
|
||||
if (decrypted) {
|
||||
list.push({
|
||||
id: entry.payloadId,
|
||||
date: decrypted.date || '',
|
||||
dayOfTravel: decrypted.dayOfTravel || '',
|
||||
departure: decrypted.departure || '',
|
||||
destination: decrypted.destination || '',
|
||||
updatedAt: entry.updatedAt,
|
||||
skipperSignStatus: await getSkipperSignStatus(decrypted as Record<string, unknown>)
|
||||
})
|
||||
const cached = entryListItemFromLocal(entry)
|
||||
if (cached) {
|
||||
list.push(cached)
|
||||
} else {
|
||||
needsDecrypt.push(entry)
|
||||
}
|
||||
}
|
||||
|
||||
await forEachInBatches(needsDecrypt, 8, async (entry) => {
|
||||
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
|
||||
if (!decrypted) return
|
||||
|
||||
const listCache = await buildEntryListCache(decrypted as Record<string, unknown>)
|
||||
list.push({
|
||||
id: entry.payloadId,
|
||||
...listCache,
|
||||
updatedAt: entry.updatedAt
|
||||
})
|
||||
void db.entries.update(entry.payloadId, { listCache }).catch((err) => {
|
||||
console.warn('Failed to persist entry list cache:', err)
|
||||
})
|
||||
})
|
||||
|
||||
// Sort chronological descending (by date, or dayOfTravel numerical)
|
||||
list.sort((a, b) => {
|
||||
const dateCompare = new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||
@@ -137,24 +160,26 @@ export default function LogEntriesList({
|
||||
setEntries(list)
|
||||
} catch (err: any) {
|
||||
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 {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [logbookId, readOnly, preloadedEntries])
|
||||
|
||||
useEffect(() => {
|
||||
if (viewMode === 'live') return
|
||||
loadEntries()
|
||||
}, [loadEntries])
|
||||
}, [loadEntries, viewMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (viewMode === 'live') return
|
||||
const prevSelectedEntryId = prevSelectedEntryIdRef.current
|
||||
prevSelectedEntryIdRef.current = selectedEntryId
|
||||
|
||||
if (prevSelectedEntryId !== undefined && prevSelectedEntryId !== null && selectedEntryId === null) {
|
||||
loadEntries()
|
||||
}
|
||||
}, [selectedEntryId, loadEntries])
|
||||
}, [selectedEntryId, loadEntries, viewMode])
|
||||
|
||||
const handleDownloadCsv = async () => {
|
||||
setExporting(true)
|
||||
@@ -169,7 +194,7 @@ export default function LogEntriesList({
|
||||
trackPlausibleEvent(PlausibleEvents.CSV_EXPORTED)
|
||||
} catch (err: any) {
|
||||
console.error('Failed to download CSV:', err)
|
||||
setError(err.message || 'Failed to generate CSV export.')
|
||||
setError(getErrorMessage(err, t('errors.export_failed')))
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
@@ -197,7 +222,7 @@ export default function LogEntriesList({
|
||||
setError(t('logs.share_unsupported'))
|
||||
} else {
|
||||
console.error('Failed to share CSV:', err)
|
||||
setError(err.message || 'Failed to share CSV export.')
|
||||
setError(getErrorMessage(err, t('errors.export_failed')))
|
||||
}
|
||||
} finally {
|
||||
setExporting(false)
|
||||
@@ -218,7 +243,7 @@ export default function LogEntriesList({
|
||||
trackPlausibleEvent(PlausibleEvents.PDF_EXPORTED, { scope: 'entry' })
|
||||
} catch (err: any) {
|
||||
console.error('Failed to download PDF:', err)
|
||||
setError(err.message || 'Failed to generate PDF export.')
|
||||
setError(getErrorMessage(err, t('errors.export_failed')))
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
@@ -231,6 +256,12 @@ export default function LogEntriesList({
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
|
||||
const existingTodayId = await findTodayEntryId(logbookId)
|
||||
if (existingTodayId) {
|
||||
setSelectedEntryId(existingTodayId)
|
||||
return
|
||||
}
|
||||
|
||||
const localEntries = await db.entries.where({ logbookId }).toArray()
|
||||
const decryptedEntries: Array<LogEntryTankSource & TravelDaySortable> = []
|
||||
|
||||
@@ -269,6 +300,12 @@ export default function LogEntriesList({
|
||||
const nowStr = new Date().toISOString()
|
||||
const todayStr = nowStr.substring(0, 10)
|
||||
|
||||
const { loadDefaultEntryCrewForNewDay } = await import('./EntryCrewSection.js')
|
||||
const entryCrew = await loadDefaultEntryCrewForNewDay(
|
||||
logbookId,
|
||||
previousEntry as Record<string, unknown> | null
|
||||
)
|
||||
|
||||
const initialPayload = {
|
||||
date: todayStr,
|
||||
dayOfTravel: getNextTravelDayNumber(decryptedEntries),
|
||||
@@ -277,6 +314,9 @@ export default function LogEntriesList({
|
||||
freshwater,
|
||||
fuel,
|
||||
...(greywaterLevel > 0 ? { greywater: { level: greywaterLevel } } : {}),
|
||||
selectedSkipperId: entryCrew.selectedSkipperId,
|
||||
selectedCrewIds: entryCrew.selectedCrewIds,
|
||||
crewSnapshotsById: entryCrew.crewSnapshotsById,
|
||||
signSkipper: '',
|
||||
signCrew: '',
|
||||
events: []
|
||||
@@ -285,14 +325,17 @@ export default function LogEntriesList({
|
||||
const encrypted = await encryptJson(initialPayload, masterKey)
|
||||
|
||||
// Save locally
|
||||
await db.entries.put({
|
||||
payloadId: localId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: nowStr
|
||||
})
|
||||
await putEntryRecord(
|
||||
{
|
||||
payloadId: localId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: nowStr
|
||||
},
|
||||
initialPayload
|
||||
)
|
||||
|
||||
// Queue for background sync
|
||||
await db.syncQueue.put({
|
||||
@@ -310,7 +353,7 @@ export default function LogEntriesList({
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
} catch (err: any) {
|
||||
console.error('Failed to create entry:', err)
|
||||
setError(err.message || 'Failed to create new log entry.')
|
||||
setError(getErrorMessage(err, t('errors.save_failed')))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -340,7 +383,7 @@ export default function LogEntriesList({
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
} catch (err: any) {
|
||||
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 +393,13 @@ export default function LogEntriesList({
|
||||
<LogEntryEditor
|
||||
entryId={selectedEntryId}
|
||||
logbookId={logbookId}
|
||||
onBack={() => setSelectedEntryId(null)}
|
||||
onBack={() => {
|
||||
setSelectedEntryId(null)
|
||||
if (returnToLiveAfterEditor) {
|
||||
setViewMode('live')
|
||||
setReturnToLiveAfterEditor(false)
|
||||
}
|
||||
}}
|
||||
readOnly={readOnly}
|
||||
preloadedEntry={preloadedEntries?.find(entry => (entry.payloadId || entry.id) === selectedEntryId)}
|
||||
preloadedPhotos={preloadedPhotos}
|
||||
@@ -359,6 +408,22 @@ export default function LogEntriesList({
|
||||
)
|
||||
}
|
||||
|
||||
if (viewMode === 'live' && !readOnly) {
|
||||
return (
|
||||
<LiveLogView
|
||||
logbookId={logbookId}
|
||||
onOpenEditor={(entryId) => {
|
||||
setReturnToLiveAfterEditor(true)
|
||||
setSelectedEntryId(entryId)
|
||||
}}
|
||||
onSwitchToList={() => {
|
||||
setViewMode('list')
|
||||
void loadEntries()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="tab-placeholder">
|
||||
@@ -374,13 +439,36 @@ export default function LogEntriesList({
|
||||
: entries[0]?.id ?? null
|
||||
|
||||
return (
|
||||
<div className="form-card">
|
||||
<div className="logs-journal">
|
||||
<div className="section-title-bar mb-6">
|
||||
<div className="form-header" style={{ margin: 0 }}>
|
||||
<Calendar size={24} className="form-icon" />
|
||||
<h2>{t('logs.title')}</h2>
|
||||
</div>
|
||||
<div className="section-toolbar">
|
||||
{!readOnly && (
|
||||
<div className="logs-view-toggle" role="group" aria-label={t('logs.view_mode_label')}>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn secondary logs-view-toggle-btn ${viewMode === 'list' ? 'is-active' : ''}`}
|
||||
onClick={() => setViewMode('list')}
|
||||
title={t('logs.view_list')}
|
||||
>
|
||||
<List size={16} />
|
||||
<span className="hide-mobile">{t('logs.view_list')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn secondary logs-view-toggle-btn ${viewMode === 'live' ? 'is-active' : ''}`}
|
||||
onClick={() => setViewMode('live')}
|
||||
title={t('logs.live_mode')}
|
||||
>
|
||||
<Radio size={16} />
|
||||
<span className="hide-mobile">{t('logs.live_mode')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button className="btn secondary" onClick={handleDownloadCsv} disabled={loading || exporting || entries.length === 0} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.export_csv')}>
|
||||
<Download size={16} />
|
||||
<span className="hide-mobile">{exporting ? t('logs.exporting') : t('logs.export_csv')}</span>
|
||||
@@ -411,9 +499,19 @@ export default function LogEntriesList({
|
||||
key={item.id}
|
||||
className="logbook-card glass"
|
||||
data-tour={tourFirstEntryId === item.id ? 'entry-first' : undefined}
|
||||
onClick={() => setSelectedEntryId(item.id)}
|
||||
>
|
||||
<div className="card-icon">
|
||||
<button
|
||||
type="button"
|
||||
className="logbook-card-select"
|
||||
onClick={() => setSelectedEntryId(item.id)}
|
||||
aria-label={
|
||||
item.departure && item.destination
|
||||
? `${item.departure} → ${item.destination}, ${t('logs.travel_day_number', { number: item.dayOfTravel })}`
|
||||
: `${t('logs.new_entry')}, ${t('logs.travel_day_number', { number: item.dayOfTravel })}`
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="card-icon" aria-hidden>
|
||||
<FileText size={24} />
|
||||
</div>
|
||||
|
||||
@@ -425,7 +523,7 @@ export default function LogEntriesList({
|
||||
</h3>
|
||||
<div className="card-meta">
|
||||
<span className="sync-badge synced">
|
||||
{t('logs.day_of_travel')} {item.dayOfTravel}
|
||||
{t('logs.travel_day_number', { number: item.dayOfTravel })}
|
||||
</span>
|
||||
<EntrySkipperSignBadge status={item.skipperSignStatus} />
|
||||
<span className="date-badge">
|
||||
@@ -434,6 +532,8 @@ export default function LogEntriesList({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChevronRight size={18} className="logbook-card-chevron" aria-hidden />
|
||||
|
||||
<button className="btn-pdf" onClick={(e) => handleDownloadPdf(item.id, item.date, e)} title={t('logs.export_pdf')} disabled={exporting}>
|
||||
<Download size={18} />
|
||||
</button>
|
||||
@@ -443,8 +543,6 @@ export default function LogEntriesList({
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<ChevronRight size={18} style={{ color: '#475569', marginLeft: 'auto' }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -5,10 +5,15 @@ import { getActiveMasterKey } from '../services/auth.js'
|
||||
import { getLogbookKey } from '../services/logbookKeys.js'
|
||||
import { encryptJson, decryptJson } from '../services/crypto.js'
|
||||
import { syncLogbook } from '../services/sync.js'
|
||||
import { saveEntryDraft, clearEntryDraft } from '../services/entryDraft.js'
|
||||
import { getErrorMessage } from '../utils/errors.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 PhotoCapture from './PhotoCapture.tsx'
|
||||
import SignatureSection from './SignatureSection.tsx'
|
||||
import EntryCrewSection from './EntryCrewSection.tsx'
|
||||
import { emptyEntryCrewFields, type EntryCrewFields } from '../types/person.js'
|
||||
import { entryCrewFromPreviousEntry } from '../utils/personSnapshots.js'
|
||||
import TrackMap from './TrackMap.tsx'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import {
|
||||
@@ -25,9 +30,10 @@ import type { SignatureValue } from '../types/signatures.js'
|
||||
import { buildLogEntryPayload, sortLogEventsByTime, normalizeLogEvent, hasUnsavedEventDraft, currentLocalTimeHHMM, isValidTimeHHMM, type LogEventPayload } from '../utils/logEntryPayload.js'
|
||||
import EventTimeInput24h from './EventTimeInput24h.tsx'
|
||||
import CourseDialInput from './CourseDialInput.tsx'
|
||||
import { degreesToCardinal } from '../utils/courseAngle.js'
|
||||
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
|
||||
import { hashEntryForSigning } from '../utils/entryCanonicalHash.js'
|
||||
import { signLogEntry } from '../services/entrySigning.js'
|
||||
import { putEntryRecord } from '../utils/entryListCache.js'
|
||||
import { getLogbookAccess } from '../services/logbookAccess.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
|
||||
@@ -37,12 +43,39 @@ import {
|
||||
deleteTrack,
|
||||
downloadTrackFile,
|
||||
parseTrackFile,
|
||||
type SavedTrack
|
||||
type SavedTrack,
|
||||
type TrackWaypoint
|
||||
} 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 { computeFuelPerMotorHour, formatFuelPerMotorHour } from '../utils/fuelStats.js'
|
||||
import { useRegisterUnsavedChanges } from '../context/UnsavedChangesContext.tsx'
|
||||
import TankLiterInput from './TankLiterInput.tsx'
|
||||
import MetricRangeInput from './MetricRangeInput.tsx'
|
||||
import {
|
||||
formatHeelDeg,
|
||||
formatPressureHpa,
|
||||
formatSeaState,
|
||||
formatVisibilityMeters,
|
||||
HEEL_MAX_DEG,
|
||||
HEEL_MIN_DEG,
|
||||
parseHeelDeg,
|
||||
parsePressureHpa,
|
||||
parseSeaState,
|
||||
parseVisibilityMeters,
|
||||
PRESSURE_DEFAULT_HPA,
|
||||
PRESSURE_MAX_HPA,
|
||||
PRESSURE_MIN_HPA,
|
||||
SEA_STATE_MAX,
|
||||
SEA_STATE_MIN,
|
||||
VISIBILITY_STEPS_M
|
||||
} from '../utils/weatherMetrics.js'
|
||||
import {
|
||||
computeEveningTankMaxLiters,
|
||||
computeRefilledTankMaxLiters,
|
||||
@@ -98,7 +131,8 @@ function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string
|
||||
motorHoursRaw != null && motorHoursRaw !== ''
|
||||
? parseFloat(String(motorHoursRaw))
|
||||
: undefined,
|
||||
events: (decrypted.events as LogEventPayload[]) || []
|
||||
events: (decrypted.events as LogEventPayload[]) || [],
|
||||
entryCrew: entryCrewFromPreviousEntry(decrypted as Record<string, unknown>)
|
||||
})
|
||||
|
||||
return JSON.stringify({
|
||||
@@ -158,6 +192,8 @@ export default function LogEntryEditor({
|
||||
const [greywaterLevel, setGreywaterLevel] = useState('0')
|
||||
const [tankCapacities, setTankCapacities] = useState<VesselTankCapacities>({})
|
||||
|
||||
const [entryCrew, setEntryCrew] = useState<EntryCrewFields>(emptyEntryCrewFields())
|
||||
|
||||
// Signatures
|
||||
const [signSkipper, setSignSkipper] = useState<SignatureValue | ''>('')
|
||||
const [signCrew, setSignCrew] = useState<SignatureValue | ''>('')
|
||||
@@ -185,6 +221,7 @@ export default function LogEntryEditor({
|
||||
const [evWindDirection, setEvWindDirection] = useState('')
|
||||
const [evWindStrength, setEvWindStrength] = useState('')
|
||||
const [evSeaState, setEvSeaState] = useState('')
|
||||
const [evVisibility, setEvVisibility] = useState('')
|
||||
const [evWeatherIcon, setEvWeatherIcon] = useState('')
|
||||
const [evCurrent, setEvCurrent] = useState('')
|
||||
const [evHeel, setEvHeel] = useState('')
|
||||
@@ -210,6 +247,8 @@ export default function LogEntryEditor({
|
||||
const [savedTrack, setSavedTrack] = useState<SavedTrack | null>(null)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
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 lockedContentHashRef = useRef<string | null>(null)
|
||||
const contentReadyRef = useRef(false)
|
||||
@@ -267,7 +306,8 @@ export default function LogEntryEditor({
|
||||
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
|
||||
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
|
||||
motorHours: motorHours.trim() ? parseFloat(motorHours) : undefined,
|
||||
events: eventsOverride ?? events
|
||||
events: eventsOverride ?? events,
|
||||
entryCrew
|
||||
})
|
||||
}, [
|
||||
date, dayOfTravel, departure, destination,
|
||||
@@ -275,9 +315,18 @@ export default function LogEntryEditor({
|
||||
fuelMorning, fuelRefilled, fuelEvening, fuelConsumption,
|
||||
greywaterLevel,
|
||||
trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn, motorHours,
|
||||
events
|
||||
events,
|
||||
entryCrew
|
||||
])
|
||||
|
||||
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(
|
||||
() => computeFuelPerMotorHour(parseFloat(fuelConsumption) || 0, parseFloat(motorHours) || 0),
|
||||
[fuelConsumption, motorHours]
|
||||
@@ -333,6 +382,7 @@ export default function LogEntryEditor({
|
||||
windDirection: evWindDirection,
|
||||
windStrength: evWindStrength,
|
||||
seaState: evSeaState,
|
||||
visibility: evVisibility,
|
||||
weatherIcon: evWeatherIcon,
|
||||
current: evCurrent,
|
||||
heel: evHeel,
|
||||
@@ -355,7 +405,7 @@ export default function LogEntryEditor({
|
||||
return hasUnsavedEventDraft(buildEventFromForm(), editingEventIndex, events)
|
||||
}, [
|
||||
evTime, evMgk, evRwk, evWindPressure, evWindDirection, evWindStrength, evSeaState,
|
||||
evWeatherIcon, evCurrent, evHeel, evSailsOrMotor, evLogReading, evDistance,
|
||||
evVisibility, evWeatherIcon, evCurrent, evHeel, evSailsOrMotor, evLogReading, evDistance,
|
||||
evGpsLat, evGpsLng, evRemarks, editingEventIndex, events
|
||||
])
|
||||
|
||||
@@ -363,9 +413,15 @@ export default function LogEntryEditor({
|
||||
currentFingerprint !== savedFingerprint || hasPendingEventForm
|
||||
)
|
||||
|
||||
const saveBeforeLeaveRef = useRef<(() => Promise<void>) | null>(null)
|
||||
const invokeSaveBeforeLeave = useCallback(async () => {
|
||||
if (saveBeforeLeaveRef.current) await saveBeforeLeaveRef.current()
|
||||
}, [])
|
||||
|
||||
const { confirmLeave } = useRegisterUnsavedChanges(
|
||||
`log-entry-${entryId}`,
|
||||
!readOnly && !loading && isDirty
|
||||
!readOnly && !loading && isDirty,
|
||||
invokeSaveBeforeLeave
|
||||
)
|
||||
|
||||
const handleBack = async () => {
|
||||
@@ -399,14 +455,17 @@ export default function LogEntryEditor({
|
||||
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 putEntryRecord(
|
||||
{
|
||||
payloadId: entryId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
},
|
||||
entryData
|
||||
)
|
||||
|
||||
await db.syncQueue.put({
|
||||
action: 'update',
|
||||
@@ -621,33 +680,25 @@ export default function LogEntryEditor({
|
||||
}
|
||||
}, [fuelEveningMax, fuelEvening])
|
||||
|
||||
// Load yacht sails and tank capacities
|
||||
// Load vessel sails and tank capacities
|
||||
useEffect(() => {
|
||||
async function loadYachtMeta() {
|
||||
if (readOnly && preloadedYacht) {
|
||||
if (preloadedYacht.sails) setYachtSails(preloadedYacht.sails)
|
||||
setTankCapacities(extractTankCapacitiesFromYacht(preloadedYacht))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) return
|
||||
|
||||
const yacht = await db.yachts.get(logbookId)
|
||||
if (yacht) {
|
||||
const decrypted = await decryptJson(yacht.encryptedData, yacht.iv, yacht.tag, masterKey)
|
||||
if (decrypted) {
|
||||
if (decrypted.sails && Array.isArray(decrypted.sails)) {
|
||||
setYachtSails(decrypted.sails)
|
||||
}
|
||||
setTankCapacities(extractTankCapacitiesFromYacht(decrypted))
|
||||
}
|
||||
const { resolveVesselForLogbook } = await import('../services/resolveVessel.js')
|
||||
const vessel =
|
||||
readOnly && preloadedYacht
|
||||
? (preloadedYacht as Record<string, unknown>)
|
||||
: await resolveVesselForLogbook(logbookId, { preloadedYacht: preloadedYacht ?? undefined })
|
||||
if (!vessel) return
|
||||
if (vessel.sails && Array.isArray(vessel.sails)) {
|
||||
setYachtSails(vessel.sails)
|
||||
}
|
||||
setTankCapacities(extractTankCapacitiesFromYacht(vessel))
|
||||
} catch (err) {
|
||||
console.error('Failed to load yacht meta in editor:', err)
|
||||
console.error('Failed to load vessel meta in editor:', err)
|
||||
}
|
||||
}
|
||||
loadYachtMeta()
|
||||
void loadYachtMeta()
|
||||
}, [logbookId, preloadedYacht, readOnly])
|
||||
|
||||
// Load entry details
|
||||
@@ -686,6 +737,7 @@ export default function LogEntryEditor({
|
||||
|
||||
setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '')
|
||||
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
|
||||
setEntryCrew(entryCrewFromPreviousEntry(preloadedEntry as Record<string, unknown>))
|
||||
loadTrackStatsFromEntry(preloadedEntry)
|
||||
setEvents(sortLogEventsByTime((preloadedEntry.events || []).map(normalizeLogEvent)))
|
||||
setSavedFingerprint(fingerprintFromStoredEntry(preloadedEntry))
|
||||
@@ -724,6 +776,7 @@ export default function LogEntryEditor({
|
||||
|
||||
setSignSkipper(normalizeSignature(decrypted.signSkipper) || '')
|
||||
setSignCrew(normalizeSignature(decrypted.signCrew) || '')
|
||||
setEntryCrew(entryCrewFromPreviousEntry(decrypted as Record<string, unknown>))
|
||||
loadTrackStatsFromEntry(decrypted)
|
||||
setEvents(sortLogEventsByTime((decrypted.events || []).map(normalizeLogEvent)))
|
||||
setSavedFingerprint(fingerprintFromStoredEntry(decrypted))
|
||||
@@ -762,6 +815,45 @@ export default function LogEntryEditor({
|
||||
loadTrack()
|
||||
}, [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(() => {
|
||||
if (!savedTrack || savedTrack.waypoints.length < 2) return
|
||||
if (trackDistanceNm || trackSpeedMaxKn || trackSpeedAvgKn) return
|
||||
@@ -851,7 +943,10 @@ export default function LogEntryEditor({
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fetchOpenWeatherCurrent({ q: locationQuery })
|
||||
const data = await fetchOpenWeatherCurrent(
|
||||
{ q: locationQuery },
|
||||
{ analyticsSource: 'entry_editor_gps_lookup' }
|
||||
)
|
||||
const coord = data.coord as { lat?: number; lon?: number } | undefined
|
||||
if (coord?.lat !== undefined && coord?.lon !== undefined) {
|
||||
setEvGpsLat(Number(coord.lat).toFixed(6))
|
||||
@@ -906,7 +1001,8 @@ export default function LogEntryEditor({
|
||||
const data = await fetchOpenWeatherCurrent(
|
||||
hasGps
|
||||
? { lat: evGpsLat, lon: evGpsLng }
|
||||
: { q: fallbackLocation }
|
||||
: { q: fallbackLocation },
|
||||
{ analyticsSource: 'entry_editor' }
|
||||
)
|
||||
|
||||
const coord = data.coord as { lat?: number; lon?: number } | undefined
|
||||
@@ -916,38 +1012,12 @@ export default function LogEntryEditor({
|
||||
setEvGpsLng(Number(coord.lon).toFixed(6))
|
||||
}
|
||||
|
||||
const wind = data.wind as { speed?: number; deg?: number } | undefined
|
||||
const main = data.main as { pressure?: number } | undefined
|
||||
|
||||
// Convert wind speed m/s to Beaufort scale
|
||||
const mps = wind?.speed || 0
|
||||
let bft = 0
|
||||
if (mps < 0.3) bft = 0
|
||||
else if (mps < 1.6) bft = 1
|
||||
else if (mps < 3.4) bft = 2
|
||||
else if (mps < 5.5) bft = 3
|
||||
else if (mps < 8.0) bft = 4
|
||||
else if (mps < 10.8) bft = 5
|
||||
else if (mps < 13.9) bft = 6
|
||||
else if (mps < 17.2) bft = 7
|
||||
else if (mps < 20.8) bft = 8
|
||||
else if (mps < 24.5) bft = 9
|
||||
else if (mps < 28.5) bft = 10
|
||||
else if (mps < 32.7) bft = 11
|
||||
else bft = 12
|
||||
|
||||
setEvWindStrength(`${bft} Bft (${mps.toFixed(1)} m/s)`)
|
||||
setEvWindPressure(String(main?.pressure || ''))
|
||||
|
||||
// Calculate wind compass direction sector
|
||||
if (wind?.deg !== undefined) {
|
||||
setEvWindDirection(degreesToCardinal(wind.deg))
|
||||
}
|
||||
|
||||
if (data.weather && Array.isArray(data.weather) && data.weather[0]) {
|
||||
const first = data.weather[0] as { icon?: string }
|
||||
if (first.icon) setEvWeatherIcon(first.icon)
|
||||
}
|
||||
const parsed = parseOwmCurrentWeather(data)
|
||||
setEvWindStrength(parsed.windStrength)
|
||||
setEvWindPressure(parsed.windPressure)
|
||||
if (parsed.windDirection) setEvWindDirection(parsed.windDirection)
|
||||
if (parsed.visibility) setEvVisibility(parsed.visibility)
|
||||
if (parsed.weatherIcon) setEvWeatherIcon(parsed.weatherIcon)
|
||||
|
||||
showAlert(t('settings.weather_success'))
|
||||
} catch (err) {
|
||||
@@ -1009,6 +1079,7 @@ export default function LogEntryEditor({
|
||||
setEvWindDirection('')
|
||||
setEvWindStrength('')
|
||||
setEvSeaState('')
|
||||
setEvVisibility('')
|
||||
setEvWeatherIcon('')
|
||||
setEvCurrent('')
|
||||
setEvHeel('')
|
||||
@@ -1032,6 +1103,7 @@ export default function LogEntryEditor({
|
||||
setEvWindDirection(normalized.windDirection)
|
||||
setEvWindStrength(normalized.windStrength)
|
||||
setEvSeaState(normalized.seaState)
|
||||
setEvVisibility(normalized.visibility)
|
||||
setEvWeatherIcon(normalized.weatherIcon)
|
||||
setEvCurrent(normalized.current)
|
||||
setEvHeel(normalized.heel)
|
||||
@@ -1145,8 +1217,7 @@ export default function LogEntryEditor({
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const saveEntryChanges = useCallback(async () => {
|
||||
if (readOnly) return
|
||||
|
||||
let eventsToSave = events
|
||||
@@ -1174,7 +1245,6 @@ export default function LogEntryEditor({
|
||||
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setSuccess(false)
|
||||
|
||||
try {
|
||||
await persistEntryToDb({
|
||||
@@ -1182,17 +1252,36 @@ export default function LogEntryEditor({
|
||||
...signaturesForSave
|
||||
})
|
||||
|
||||
setSuccess(true)
|
||||
await clearEntryDraft(logbookId, entryId)
|
||||
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [
|
||||
readOnly, events, hasPendingEventForm, editingEventIndex, isDirty,
|
||||
resolveSignaturesAfterContentChange, applyEventFormToEvents, buildEventFromForm,
|
||||
clearEventForm, persistEntryToDb, logbookId, entryId, t
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
saveBeforeLeaveRef.current = readOnly ? null : saveEntryChanges
|
||||
}, [readOnly, saveEntryChanges])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (readOnly) return
|
||||
|
||||
setSuccess(false)
|
||||
try {
|
||||
await saveEntryChanges()
|
||||
setSuccess(true)
|
||||
setTimeout(() => {
|
||||
setSuccess(false)
|
||||
onBack()
|
||||
}, 1500)
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to save entry details:', err)
|
||||
setError(err.message || 'Failed to save entry details.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
setError(getErrorMessage(err, t('errors.save_failed')))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1658,8 +1747,8 @@ export default function LogEntryEditor({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-grid mb-4">
|
||||
<div className="input-group course-dial-section">
|
||||
<div className="form-grid weather-metrics-grid mb-4">
|
||||
<div className="input-group course-dial-section weather-metrics-span-2">
|
||||
<label>{t('logs.event_wind_direction')}</label>
|
||||
<CourseDialInput
|
||||
value={evWindDirection}
|
||||
@@ -1683,41 +1772,76 @@ export default function LogEntryEditor({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.event_wind_pressure')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. 1013 hPa"
|
||||
className="input-text"
|
||||
value={evWindPressure}
|
||||
onChange={(e) => setEvWindPressure(e.target.value)}
|
||||
disabled={saving || weatherLoading}
|
||||
/>
|
||||
</div>
|
||||
<MetricRangeInput
|
||||
label={t('logs.event_wind_pressure')}
|
||||
value={evWindPressure}
|
||||
onChange={setEvWindPressure}
|
||||
disabled={saving || weatherLoading}
|
||||
min={PRESSURE_MIN_HPA}
|
||||
max={PRESSURE_MAX_HPA}
|
||||
step={1}
|
||||
defaultNumeric={PRESSURE_DEFAULT_HPA}
|
||||
parse={parsePressureHpa}
|
||||
format={formatPressureHpa}
|
||||
formatDisplay={(hpa) =>
|
||||
t('logs.weather_slider_pressure', { value: hpa, defaultValue: `${hpa} hPa` })}
|
||||
numberMin={PRESSURE_MIN_HPA}
|
||||
numberMax={PRESSURE_MAX_HPA}
|
||||
numberStep={1}
|
||||
numberPlaceholder="1013"
|
||||
/>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.event_sea_state')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. 3"
|
||||
className="input-text"
|
||||
value={evSeaState}
|
||||
onChange={(e) => setEvSeaState(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
<MetricRangeInput
|
||||
label={t('logs.event_sea_state')}
|
||||
value={evSeaState}
|
||||
onChange={setEvSeaState}
|
||||
disabled={saving}
|
||||
min={SEA_STATE_MIN}
|
||||
max={SEA_STATE_MAX}
|
||||
step={1}
|
||||
defaultNumeric={0}
|
||||
parse={parseSeaState}
|
||||
format={formatSeaState}
|
||||
formatDisplay={(level) =>
|
||||
t('logs.weather_slider_sea_state', { value: level, defaultValue: `${level}` })}
|
||||
numberMin={SEA_STATE_MIN}
|
||||
numberMax={SEA_STATE_MAX}
|
||||
numberStep={1}
|
||||
numberPlaceholder="3"
|
||||
allowLegacyText
|
||||
/>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.event_heel')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. 5"
|
||||
className="input-text"
|
||||
value={evHeel}
|
||||
onChange={(e) => setEvHeel(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
<MetricRangeInput
|
||||
label={t('logs.event_visibility')}
|
||||
value={evVisibility}
|
||||
onChange={setEvVisibility}
|
||||
disabled={saving || weatherLoading}
|
||||
discreteValues={VISIBILITY_STEPS_M}
|
||||
defaultNumeric={10000}
|
||||
parse={parseVisibilityMeters}
|
||||
format={formatVisibilityMeters}
|
||||
formatDisplay={(m) => formatVisibilityMeters(m)}
|
||||
hideNumberInput
|
||||
/>
|
||||
|
||||
<MetricRangeInput
|
||||
label={t('logs.event_heel')}
|
||||
value={evHeel}
|
||||
onChange={setEvHeel}
|
||||
disabled={saving}
|
||||
min={HEEL_MIN_DEG}
|
||||
max={HEEL_MAX_DEG}
|
||||
step={1}
|
||||
defaultNumeric={0}
|
||||
parse={parseHeelDeg}
|
||||
format={formatHeelDeg}
|
||||
formatDisplay={(deg) =>
|
||||
t('logs.weather_slider_heel', { value: deg, defaultValue: `${deg}°` })}
|
||||
numberMin={HEEL_MIN_DEG}
|
||||
numberMax={HEEL_MAX_DEG}
|
||||
numberStep={1}
|
||||
numberPlaceholder="5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-grid mb-4">
|
||||
@@ -1925,6 +2049,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) && (
|
||||
<div className="form-grid track-stats-grid">
|
||||
<div className="input-group">
|
||||
@@ -1969,6 +2118,13 @@ export default function LogEntryEditor({
|
||||
|
||||
<PhotoCapture entryId={entryId} logbookId={logbookId} readOnly={readOnly} preloadedPhotos={preloadedPhotos} />
|
||||
|
||||
<EntryCrewSection
|
||||
logbookId={logbookId}
|
||||
readOnly={readOnly}
|
||||
value={entryCrew}
|
||||
onChange={setEntryCrew}
|
||||
/>
|
||||
|
||||
<SignatureSection
|
||||
readOnly={readOnly}
|
||||
disabled={saving}
|
||||
@@ -2030,6 +2186,19 @@ export default function LogEntryEditor({
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<NmeaImportWizard
|
||||
open={nmeaWizardOpen}
|
||||
onClose={() => {
|
||||
setNmeaWizardOpen(false)
|
||||
void loadNmeaArchive()
|
||||
}}
|
||||
logbookId={logbookId}
|
||||
entryId={entryId}
|
||||
entryDate={date}
|
||||
nmeaArchive={nmeaArchive}
|
||||
onImport={handleNmeaImport}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Users, User, Save, Check } from 'lucide-react'
|
||||
import type { LogbookCrewSelectionData, PersonSnapshot } from '../types/person.js'
|
||||
import type { DecryptedPerson } from '../services/personPool.js'
|
||||
import { loadPersonPool, filterSkippers, filterCrew } from '../services/personPool.js'
|
||||
import { loadLogbookCrewSelection, saveLogbookCrewSelectionFromIds } from '../services/logbookCrewSelection.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
|
||||
export interface LogbookCrewPickerProps {
|
||||
logbookId: string
|
||||
readOnly?: boolean
|
||||
/** Demo / share: in-memory pool */
|
||||
preloadedPool?: Array<{ payloadId: string; data: DecryptedPerson['data'] }>
|
||||
preloadedSelection?: LogbookCrewSelectionData
|
||||
/** Shared logbook: only people from selection snapshots */
|
||||
selectionOnly?: boolean
|
||||
}
|
||||
|
||||
function snapshotsToPoolList(
|
||||
selection: LogbookCrewSelectionData
|
||||
): Array<{ payloadId: string; data: DecryptedPerson['data'] }> {
|
||||
return Object.values(selection.snapshotsById).map((snap) => ({
|
||||
payloadId: snap.id,
|
||||
data: {
|
||||
name: snap.name,
|
||||
address: snap.address,
|
||||
birthDate: snap.birthDate,
|
||||
phone: snap.phone,
|
||||
nationality: snap.nationality,
|
||||
passportNumber: snap.passportNumber,
|
||||
bloodType: snap.bloodType,
|
||||
allergies: snap.allergies,
|
||||
diseases: snap.diseases,
|
||||
role: snap.role,
|
||||
photo: snap.photo
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
export default function LogbookCrewPicker({
|
||||
logbookId,
|
||||
readOnly = false,
|
||||
preloadedPool,
|
||||
preloadedSelection,
|
||||
selectionOnly = false
|
||||
}: LogbookCrewPickerProps) {
|
||||
const { t } = useTranslation()
|
||||
const [loading, setLoading] = useState(!preloadedSelection)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [pool, setPool] = useState<DecryptedPerson[]>([])
|
||||
const [activeSkipperId, setActiveSkipperId] = useState<string | null>(null)
|
||||
const [activeCrewIds, setActiveCrewIds] = useState<string[]>([])
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const selection =
|
||||
preloadedSelection ??
|
||||
(logbookId === 'demo' ? null : await loadLogbookCrewSelection(logbookId))
|
||||
|
||||
if (selection) {
|
||||
setActiveSkipperId(selection.activeSkipperId)
|
||||
setActiveCrewIds([...selection.activeCrewIds])
|
||||
}
|
||||
|
||||
if (preloadedPool) {
|
||||
setPool(
|
||||
preloadedPool.map((p) => ({
|
||||
payloadId: p.payloadId,
|
||||
data: p.data
|
||||
}))
|
||||
)
|
||||
} else if (selectionOnly && selection) {
|
||||
setPool(snapshotsToPoolList(selection))
|
||||
} else {
|
||||
setPool(await loadPersonPool())
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load crew selection')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [logbookId, preloadedPool, preloadedSelection, selectionOnly])
|
||||
|
||||
useEffect(() => {
|
||||
void loadData()
|
||||
}, [loadData])
|
||||
|
||||
const skippers = useMemo(() => filterSkippers(pool), [pool])
|
||||
const crewMembers = useMemo(() => filterCrew(pool), [pool])
|
||||
|
||||
const toggleCrew = (id: string) => {
|
||||
if (readOnly) return
|
||||
setActiveCrewIds((prev) =>
|
||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||
)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (readOnly || logbookId === 'demo') return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setSaved(false)
|
||||
try {
|
||||
await saveLogbookCrewSelectionFromIds(logbookId, activeSkipperId, activeCrewIds)
|
||||
setSaved(true)
|
||||
trackPlausibleEvent(PlausibleEvents.CREW_SAVED, { context: 'logbook_selection' })
|
||||
setTimeout(() => setSaved(false), 3000)
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="tab-placeholder">
|
||||
<Users className="header-logo spin" size={48} />
|
||||
<p>{t('person_pool.loading')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="crew-dashboard-layout" data-tour="logbook-crew-picker">
|
||||
<div className="form-card">
|
||||
<div className="form-header">
|
||||
<Users size={24} className="form-icon" />
|
||||
<h2>{t('logbook_crew.title')}</h2>
|
||||
</div>
|
||||
<p className="help-text mb-4">{t('logbook_crew.subtitle')}</p>
|
||||
{selectionOnly && <p className="help-text mb-4">{t('logbook_crew.selection_only_hint')}</p>}
|
||||
{error && <div className="auth-error mb-4">{error}</div>}
|
||||
|
||||
<div className="input-group mb-4">
|
||||
<label>{t('logbook_crew.active_skipper')}</label>
|
||||
{skippers.length === 0 ? (
|
||||
<p className="help-text">{t('logbook_crew.no_skippers_in_pool')}</p>
|
||||
) : (
|
||||
<div className="crew-selection-list">
|
||||
{skippers.map((s) => (
|
||||
<label key={s.payloadId} className="crew-selection-item">
|
||||
<input
|
||||
type="radio"
|
||||
name={`skipper-${logbookId}`}
|
||||
checked={activeSkipperId === s.payloadId}
|
||||
onChange={() => !readOnly && setActiveSkipperId(s.payloadId)}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<User size={16} aria-hidden="true" />
|
||||
<span>{s.data.name || t('logbook_crew.unnamed')}</span>
|
||||
</label>
|
||||
))}
|
||||
{!readOnly && (
|
||||
<label className="crew-selection-item">
|
||||
<input
|
||||
type="radio"
|
||||
name={`skipper-${logbookId}`}
|
||||
checked={activeSkipperId === null}
|
||||
onChange={() => setActiveSkipperId(null)}
|
||||
/>
|
||||
<span>{t('logbook_crew.no_skipper')}</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="input-group mb-4">
|
||||
<label>{t('logbook_crew.active_crew')}</label>
|
||||
{crewMembers.length === 0 ? (
|
||||
<p className="help-text">{t('logbook_crew.no_crew_in_pool')}</p>
|
||||
) : (
|
||||
<div className="crew-selection-list">
|
||||
{crewMembers.map((c) => (
|
||||
<label key={c.payloadId} className="crew-selection-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={activeCrewIds.includes(c.payloadId)}
|
||||
onChange={() => toggleCrew(c.payloadId)}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<span>{c.data.name || t('logbook_crew.unnamed')}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!readOnly && logbookId !== 'demo' && (
|
||||
<div className="form-actions">
|
||||
{saved && (
|
||||
<div className="success-toast">
|
||||
<Check size={16} />
|
||||
<span>{t('logbook_crew.saved')}</span>
|
||||
</div>
|
||||
)}
|
||||
<button type="button" className="btn primary" onClick={() => void handleSave()} disabled={saving}>
|
||||
<Save size={18} />
|
||||
{t('logbook_crew.save')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function selectionFromSnapshots(
|
||||
snapshotsById: Record<string, PersonSnapshot>
|
||||
): LogbookCrewSelectionData {
|
||||
const snapshots = Object.values(snapshotsById)
|
||||
const skipper = snapshots.find((s) => s.role === 'skipper')
|
||||
return {
|
||||
activeSkipperId: skipper?.id ?? null,
|
||||
activeCrewIds: snapshots.filter((s) => s.role === 'crew').map((s) => s.id),
|
||||
snapshotsById
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cycleAppLanguage } from '../utils/i18nLanguages.js'
|
||||
import { useSyncIndicator } from '../hooks/useSyncIndicator.js'
|
||||
import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type DecryptedLogbook } from '../services/logbook.js'
|
||||
import { loadLogbookSearchFieldsBatch } from '../services/logbookSearchIndex.js'
|
||||
import { logbookMatchesFilter, type LogbookSearchFields } from '../utils/logbookFilter.js'
|
||||
import LogbookRoleBadge from './LogbookRoleBadge.tsx'
|
||||
import BetaBadge from './BetaBadge.tsx'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { getErrorMessage } from '../utils/errors.js'
|
||||
import { logoutUser } from '../services/auth.js'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, User, Wifi, WifiOff } 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 FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
|
||||
import ProfileHeaderButton from './ProfileHeaderButton.tsx'
|
||||
|
||||
interface LogbookDashboardProps {
|
||||
onSelectLogbook: (id: string, title: string) => void
|
||||
@@ -17,6 +22,26 @@ interface LogbookDashboardProps {
|
||||
onOpenProfile: () => void
|
||||
}
|
||||
|
||||
type LogbookSortKey = 'name' | 'date'
|
||||
type LogbookSortDirection = 'asc' | 'desc'
|
||||
|
||||
function sortLogbooks(
|
||||
items: DecryptedLogbook[],
|
||||
sortBy: LogbookSortKey,
|
||||
direction: LogbookSortDirection,
|
||||
locale: string
|
||||
): DecryptedLogbook[] {
|
||||
const sorted = [...items]
|
||||
sorted.sort((a, b) => {
|
||||
const cmp =
|
||||
sortBy === 'name'
|
||||
? a.title.localeCompare(b.title, locale, { sensitivity: 'base' })
|
||||
: new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
|
||||
return direction === 'asc' ? cmp : -cmp
|
||||
})
|
||||
return sorted
|
||||
}
|
||||
|
||||
export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProfile }: LogbookDashboardProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { showConfirm } = useDialog()
|
||||
@@ -28,8 +53,14 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [filterQuery, setFilterQuery] = useState('')
|
||||
const [searchFieldsByLogbookId, setSearchFieldsByLogbookId] = useState<Map<string, LogbookSearchFields>>(
|
||||
() => new Map()
|
||||
)
|
||||
const [sortBy, setSortBy] = useState<LogbookSortKey>('date')
|
||||
const [sortDirection, setSortDirection] = useState<LogbookSortDirection>('desc')
|
||||
const filterInputRef = useRef<HTMLInputElement>(null)
|
||||
const [online, setOnline] = useState(navigator.onLine)
|
||||
const [username] = useState(localStorage.getItem('active_username') || 'Skipper')
|
||||
|
||||
const { pendingCount, showSpinner, showPendingWarning, connStatusClassName } = useSyncIndicator()
|
||||
|
||||
@@ -50,6 +81,23 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
||||
loadLogbooks()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const ids = logbooks.map((lb) => lb.id)
|
||||
if (ids.length === 0) {
|
||||
setSearchFieldsByLogbookId(new Map())
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
void loadLogbookSearchFieldsBatch(ids).then((index) => {
|
||||
if (!cancelled) setSearchFieldsByLogbookId(index)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [logbooks])
|
||||
|
||||
const loadLogbooks = async (isRefresh = false) => {
|
||||
if (isRefresh) setRefreshing(true)
|
||||
else setLoading(true)
|
||||
@@ -57,8 +105,8 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
||||
try {
|
||||
const data = await fetchLogbooks()
|
||||
setLogbooks(data)
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load logbooks')
|
||||
} catch (err: unknown) {
|
||||
setError(getErrorMessage(err, t('errors.load_failed')))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
@@ -76,8 +124,8 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
||||
setLogbooks((prev) => [created, ...prev])
|
||||
setNewTitle('')
|
||||
trackPlausibleEvent(PlausibleEvents.LOGBOOK_CREATED)
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to create logbook')
|
||||
} catch (err: unknown) {
|
||||
setError(getErrorMessage(err, t('errors.save_failed')))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -93,7 +141,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
||||
await deleteLogbook(id)
|
||||
setLogbooks((prev) => prev.filter((lb) => lb.id !== id))
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to delete logbook')
|
||||
setError(getErrorMessage(err, t('errors.delete_failed')))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -137,7 +185,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
||||
)
|
||||
)
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to update logbook title')
|
||||
setError(getErrorMessage(err, t('errors.save_failed')))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -149,23 +197,55 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
||||
}
|
||||
|
||||
const toggleLanguage = () => {
|
||||
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
|
||||
i18n.changeLanguage(nextLang)
|
||||
cycleAppLanguage(i18n)
|
||||
}
|
||||
|
||||
const ownedLogbooks = logbooks.filter((lb) => !lb.isShared)
|
||||
const sharedLogbooks = logbooks.filter((lb) => lb.isShared)
|
||||
|
||||
const filterActive = filterQuery.trim().length > 0
|
||||
const filteredOwnedLogbooks = useMemo(
|
||||
() =>
|
||||
ownedLogbooks.filter((lb) =>
|
||||
logbookMatchesFilter(lb, filterQuery, i18n.language, searchFieldsByLogbookId.get(lb.id))
|
||||
),
|
||||
[ownedLogbooks, filterQuery, i18n.language, searchFieldsByLogbookId]
|
||||
)
|
||||
const filteredSharedLogbooks = useMemo(
|
||||
() =>
|
||||
sharedLogbooks.filter((lb) =>
|
||||
logbookMatchesFilter(lb, filterQuery, i18n.language, searchFieldsByLogbookId.get(lb.id))
|
||||
),
|
||||
[sharedLogbooks, filterQuery, i18n.language, searchFieldsByLogbookId]
|
||||
)
|
||||
const sortedOwnedLogbooks = useMemo(
|
||||
() => sortLogbooks(filteredOwnedLogbooks, sortBy, sortDirection, i18n.language),
|
||||
[filteredOwnedLogbooks, sortBy, sortDirection, i18n.language]
|
||||
)
|
||||
const sortedSharedLogbooks = useMemo(
|
||||
() => sortLogbooks(filteredSharedLogbooks, sortBy, sortDirection, i18n.language),
|
||||
[filteredSharedLogbooks, sortBy, sortDirection, i18n.language]
|
||||
)
|
||||
const filteredLogbookCount = sortedOwnedLogbooks.length + sortedSharedLogbooks.length
|
||||
|
||||
const renderLogbookCard = (lb: DecryptedLogbook) => {
|
||||
const isEditingTitle = editingLogbookId === lb.id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={lb.id}
|
||||
className={`logbook-card glass${lb.isShared ? ' logbook-card--shared' : ''}`}
|
||||
onClick={() => onSelectLogbook(lb.id, lb.title)}
|
||||
className={`logbook-card glass${lb.isShared ? ' logbook-card--shared' : ''}${isEditingTitle ? ' logbook-card--editing-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} />
|
||||
</div>
|
||||
|
||||
@@ -178,7 +258,6 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
||||
className="logbook-title-inline-edit input-text"
|
||||
value={editingTitleDraft}
|
||||
onChange={(e) => setEditingTitleDraft(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
@@ -307,18 +386,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Skipper profile */}
|
||||
<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>
|
||||
<ProfileHeaderButton onClick={onOpenProfile} />
|
||||
|
||||
{/* Lang toggle */}
|
||||
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
|
||||
@@ -376,17 +444,115 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
||||
) : logbooks.length === 0 ? (
|
||||
<div className="dashboard-status-msg glass">{t('dashboard.no_logbooks')}</div>
|
||||
) : (
|
||||
<div className="logbook-sections">
|
||||
{ownedLogbooks.length > 0 && renderLogbookSection(
|
||||
sharedLogbooks.length > 0 ? t('dashboard.section_owned') : t('dashboard.title'),
|
||||
ownedLogbooks
|
||||
<>
|
||||
<div className="dashboard-list-controls">
|
||||
<div className="dashboard-filter-bar">
|
||||
<label className="dashboard-filter-label" htmlFor="logbook-list-filter">
|
||||
{t('dashboard.filter_label')}
|
||||
</label>
|
||||
<div className="dashboard-filter-input-wrap">
|
||||
<Search size={18} className="dashboard-filter-icon" aria-hidden="true" />
|
||||
<input
|
||||
ref={filterInputRef}
|
||||
id="logbook-list-filter"
|
||||
type="search"
|
||||
className="input-text dashboard-filter-input"
|
||||
placeholder={t('dashboard.filter_placeholder')}
|
||||
value={filterQuery}
|
||||
onChange={(e) => setFilterQuery(e.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
aria-describedby={filterActive ? 'logbook-filter-status' : undefined}
|
||||
/>
|
||||
{filterActive && (
|
||||
<button
|
||||
type="button"
|
||||
className="dashboard-filter-clear"
|
||||
onClick={() => {
|
||||
setFilterQuery('')
|
||||
filterInputRef.current?.focus()
|
||||
}}
|
||||
title={t('dashboard.filter_clear')}
|
||||
aria-label={t('dashboard.filter_clear')}
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{filterActive && (
|
||||
<p id="logbook-filter-status" className="dashboard-filter-meta" role="status">
|
||||
{t('dashboard.filter_results', { count: filteredLogbookCount })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="dashboard-sort-bar">
|
||||
<span className="dashboard-sort-label">{t('dashboard.sort_label')}</span>
|
||||
<div className="dashboard-sort-row">
|
||||
<div className="dashboard-sort-group" role="group" aria-label={t('dashboard.sort_by_label')}>
|
||||
<button
|
||||
type="button"
|
||||
className={`dashboard-sort-btn${sortBy === 'name' ? ' is-active' : ''}`}
|
||||
onClick={() => setSortBy('name')}
|
||||
aria-pressed={sortBy === 'name'}
|
||||
aria-label={t('dashboard.sort_by_name')}
|
||||
title={t('dashboard.sort_by_name')}
|
||||
>
|
||||
<CaseSensitive size={16} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`dashboard-sort-btn${sortBy === 'date' ? ' is-active' : ''}`}
|
||||
onClick={() => setSortBy('date')}
|
||||
aria-pressed={sortBy === 'date'}
|
||||
aria-label={t('dashboard.sort_by_date')}
|
||||
title={t('dashboard.sort_by_date')}
|
||||
>
|
||||
<CalendarDays size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="dashboard-sort-group" role="group" aria-label={t('dashboard.sort_dir_label')}>
|
||||
<button
|
||||
type="button"
|
||||
className={`dashboard-sort-btn${sortDirection === 'asc' ? ' is-active' : ''}`}
|
||||
onClick={() => setSortDirection('asc')}
|
||||
aria-pressed={sortDirection === 'asc'}
|
||||
aria-label={sortBy === 'name' ? t('dashboard.sort_name_asc') : t('dashboard.sort_date_asc')}
|
||||
title={sortBy === 'name' ? t('dashboard.sort_name_asc') : t('dashboard.sort_date_asc')}
|
||||
>
|
||||
<ArrowUp size={16} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`dashboard-sort-btn${sortDirection === 'desc' ? ' is-active' : ''}`}
|
||||
onClick={() => setSortDirection('desc')}
|
||||
aria-pressed={sortDirection === 'desc'}
|
||||
aria-label={sortBy === 'name' ? t('dashboard.sort_name_desc') : t('dashboard.sort_date_desc')}
|
||||
title={sortBy === 'name' ? t('dashboard.sort_name_desc') : t('dashboard.sort_date_desc')}
|
||||
>
|
||||
<ArrowDown size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filterActive && filteredLogbookCount === 0 ? (
|
||||
<div className="dashboard-status-msg glass">{t('dashboard.filter_no_results')}</div>
|
||||
) : (
|
||||
<div className="logbook-sections">
|
||||
{sortedOwnedLogbooks.length > 0 && renderLogbookSection(
|
||||
sortedSharedLogbooks.length > 0 ? t('dashboard.section_owned') : t('dashboard.title'),
|
||||
sortedOwnedLogbooks
|
||||
)}
|
||||
{sortedSharedLogbooks.length > 0 && renderLogbookSection(
|
||||
t('dashboard.section_shared'),
|
||||
sortedSharedLogbooks,
|
||||
t('dashboard.section_shared_hint')
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{sharedLogbooks.length > 0 && renderLogbookSection(
|
||||
t('dashboard.section_shared'),
|
||||
sharedLogbooks,
|
||||
t('dashboard.section_shared_hint')
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Ship, Save, Check } from 'lucide-react'
|
||||
import type { LogbookVesselSelectionData, VesselData } from '../types/vessel.js'
|
||||
import type { DecryptedVessel } from '../services/vesselPool.js'
|
||||
import { loadVesselPool } from '../services/vesselPool.js'
|
||||
import { loadLogbookVesselSelection, saveLogbookVesselSelectionFromId } from '../services/logbookVesselSelection.js'
|
||||
import { resolveVesselForLogbook } from '../services/resolveVessel.js'
|
||||
import { vesselDataFromSnapshot } from '../utils/vesselSnapshot.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
|
||||
export interface LogbookVesselPickerProps {
|
||||
logbookId: string
|
||||
readOnly?: boolean
|
||||
preloadedPool?: Array<{ payloadId: string; data: VesselData }>
|
||||
preloadedSelection?: LogbookVesselSelectionData
|
||||
selectionOnly?: boolean
|
||||
onOpenProfile?: () => void
|
||||
}
|
||||
|
||||
export default function LogbookVesselPicker({
|
||||
logbookId,
|
||||
readOnly = false,
|
||||
preloadedPool,
|
||||
preloadedSelection,
|
||||
selectionOnly = false,
|
||||
onOpenProfile
|
||||
}: LogbookVesselPickerProps) {
|
||||
const { t } = useTranslation()
|
||||
const [loading, setLoading] = useState(!preloadedSelection)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [pool, setPool] = useState<DecryptedVessel[]>([])
|
||||
const [activeVesselId, setActiveVesselId] = useState<string | null>(null)
|
||||
const [resolvedVessel, setResolvedVessel] = useState<VesselData | null>(null)
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const selection =
|
||||
preloadedSelection ??
|
||||
(logbookId === 'demo' ? null : await loadLogbookVesselSelection(logbookId))
|
||||
|
||||
if (selection) {
|
||||
setActiveVesselId(selection.activeVesselId)
|
||||
}
|
||||
|
||||
if (preloadedPool) {
|
||||
setPool(preloadedPool.map((p) => ({ payloadId: p.payloadId, data: p.data })))
|
||||
} else if (selectionOnly && selection?.vesselSnapshot) {
|
||||
const data = vesselDataFromSnapshot(selection.vesselSnapshot)
|
||||
if (data) {
|
||||
setPool([{ payloadId: selection.vesselSnapshot.id, data }])
|
||||
}
|
||||
} else {
|
||||
setPool(await loadVesselPool())
|
||||
}
|
||||
|
||||
const vessel = await resolveVesselForLogbook(logbookId, {
|
||||
preloadedSelection: selection ?? undefined
|
||||
})
|
||||
setResolvedVessel(vessel)
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load vessel selection')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [logbookId, preloadedPool, preloadedSelection, selectionOnly])
|
||||
|
||||
useEffect(() => {
|
||||
void loadData()
|
||||
}, [loadData])
|
||||
|
||||
const handleSave = async () => {
|
||||
if (readOnly || logbookId === 'demo') return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setSaved(false)
|
||||
try {
|
||||
const selection = await saveLogbookVesselSelectionFromId(logbookId, activeVesselId)
|
||||
const vessel = vesselDataFromSnapshot(selection.vesselSnapshot)
|
||||
setResolvedVessel(vessel)
|
||||
setSaved(true)
|
||||
trackPlausibleEvent(PlausibleEvents.VESSEL_SAVED, { context: 'logbook_selection' })
|
||||
setTimeout(() => setSaved(false), 3000)
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="tab-placeholder">
|
||||
<Ship className="header-logo spin" size={48} />
|
||||
<p>{t('vessel_pool.loading')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="crew-dashboard-layout" data-tour="logbook-vessel-picker">
|
||||
<div className="form-card">
|
||||
<div className="form-header">
|
||||
<Ship size={24} className="form-icon" />
|
||||
<h2>{t('logbook_vessel.title')}</h2>
|
||||
</div>
|
||||
<p className="help-text mb-4">{t('logbook_vessel.subtitle')}</p>
|
||||
{selectionOnly && <p className="help-text mb-4">{t('logbook_vessel.selection_only_hint')}</p>}
|
||||
{!selectionOnly && !readOnly && onOpenProfile && (
|
||||
<p className="help-text mb-4">
|
||||
<button type="button" className="btn-link" onClick={onOpenProfile}>
|
||||
{t('logbook_vessel.manage_in_profile')}
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
{error && <div className="auth-error mb-4">{error}</div>}
|
||||
|
||||
<div className="input-group mb-4">
|
||||
<label>{t('logbook_vessel.active_vessel')}</label>
|
||||
{pool.length === 0 ? (
|
||||
<p className="help-text">{t('logbook_vessel.no_vessels_in_pool')}</p>
|
||||
) : (
|
||||
<div className="crew-selection-list">
|
||||
{pool.map((v) => (
|
||||
<label key={v.payloadId} className="crew-selection-item">
|
||||
<input
|
||||
type="radio"
|
||||
name={`vessel-${logbookId}`}
|
||||
checked={activeVesselId === v.payloadId}
|
||||
onChange={() => !readOnly && setActiveVesselId(v.payloadId)}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<Ship size={16} aria-hidden="true" />
|
||||
<span>{v.data.name || t('logbook_vessel.unnamed')}</span>
|
||||
</label>
|
||||
))}
|
||||
{!readOnly && (
|
||||
<label className="crew-selection-item">
|
||||
<input
|
||||
type="radio"
|
||||
name={`vessel-${logbookId}`}
|
||||
checked={activeVesselId === null}
|
||||
onChange={() => setActiveVesselId(null)}
|
||||
/>
|
||||
<span>{t('logbook_vessel.no_vessel')}</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{resolvedVessel && (
|
||||
<div className="member-editor-card glass mb-4 logbook-vessel-summary">
|
||||
<h3 className="mb-2">{resolvedVessel.name}</h3>
|
||||
<dl className="profile-dl">
|
||||
{resolvedVessel.homePort && (
|
||||
<div className="profile-dl-row">
|
||||
<dt>{t('vessel.port')}</dt>
|
||||
<dd>{resolvedVessel.homePort}</dd>
|
||||
</div>
|
||||
)}
|
||||
{resolvedVessel.registrationNumber && (
|
||||
<div className="profile-dl-row">
|
||||
<dt>{t('vessel.registration')}</dt>
|
||||
<dd>{resolvedVessel.registrationNumber}</dd>
|
||||
</div>
|
||||
)}
|
||||
{resolvedVessel.mmsi && (
|
||||
<div className="profile-dl-row">
|
||||
<dt>{t('vessel.mmsi')}</dt>
|
||||
<dd>{resolvedVessel.mmsi}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!readOnly && logbookId !== 'demo' && (
|
||||
<div className="form-actions">
|
||||
{saved && (
|
||||
<div className="success-toast">
|
||||
<Check size={16} />
|
||||
<span>{t('logbook_vessel.saved')}</span>
|
||||
</div>
|
||||
)}
|
||||
<button type="button" className="btn primary" onClick={() => void handleSave()} disabled={saving}>
|
||||
<Save size={18} />
|
||||
{t('logbook_vessel.save')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export interface MetricRangeInputProps {
|
||||
id?: string
|
||||
label: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
disabled?: boolean
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
discreteValues?: readonly number[]
|
||||
parse: (value: string) => number | null
|
||||
format: (numeric: number) => string
|
||||
defaultNumeric: number
|
||||
/** Shown next to the label (current value). */
|
||||
formatDisplay: (numeric: number, unset: boolean) => string
|
||||
numberMin?: number
|
||||
numberMax?: number
|
||||
numberStep?: number | 'any'
|
||||
numberPlaceholder?: string
|
||||
allowLegacyText?: boolean
|
||||
hideNumberInput?: boolean
|
||||
}
|
||||
|
||||
function clamp(n: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, n))
|
||||
}
|
||||
|
||||
export default function MetricRangeInput({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
min,
|
||||
max,
|
||||
discreteValues,
|
||||
parse,
|
||||
format,
|
||||
defaultNumeric,
|
||||
formatDisplay,
|
||||
numberMin,
|
||||
numberMax,
|
||||
numberStep = 'any',
|
||||
numberPlaceholder,
|
||||
allowLegacyText = false,
|
||||
hideNumberInput = false
|
||||
}: MetricRangeInputProps) {
|
||||
const { t } = useTranslation()
|
||||
const unsetLabel = t('logs.weather_slider_unset', { defaultValue: '—' })
|
||||
|
||||
const isLegacyText =
|
||||
allowLegacyText && value.trim() !== '' && parse(value) === null
|
||||
|
||||
const emitNumeric = useCallback(
|
||||
(numeric: number) => {
|
||||
onChange(format(numeric))
|
||||
},
|
||||
[onChange, format]
|
||||
)
|
||||
|
||||
const parsed = parse(value)
|
||||
const unset = parsed === null
|
||||
const sliderNumeric = unset ? defaultNumeric : parsed
|
||||
|
||||
const useDiscrete = discreteValues != null && discreteValues.length > 1
|
||||
|
||||
let sliderMin = 0
|
||||
let sliderMax = 0
|
||||
let sliderValue = 0
|
||||
|
||||
if (useDiscrete) {
|
||||
sliderMin = 0
|
||||
sliderMax = discreteValues.length - 1
|
||||
if (unset) {
|
||||
sliderValue = 0
|
||||
} else {
|
||||
let bestIdx = 0
|
||||
let bestDiff = Math.abs(discreteValues[0] - sliderNumeric)
|
||||
for (let i = 1; i < discreteValues.length; i++) {
|
||||
const diff = Math.abs(discreteValues[i] - sliderNumeric)
|
||||
if (diff < bestDiff) {
|
||||
bestDiff = diff
|
||||
bestIdx = i
|
||||
}
|
||||
}
|
||||
sliderValue = bestIdx
|
||||
}
|
||||
} else if (min != null && max != null) {
|
||||
sliderMin = min
|
||||
sliderMax = max
|
||||
sliderValue = clamp(sliderNumeric, min, max)
|
||||
}
|
||||
|
||||
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const idx = Number(e.target.value)
|
||||
if (useDiscrete && discreteValues) {
|
||||
emitNumeric(discreteValues[clamp(idx, 0, discreteValues.length - 1)])
|
||||
return
|
||||
}
|
||||
if (min != null && max != null) {
|
||||
emitNumeric(Number(e.target.value))
|
||||
}
|
||||
}
|
||||
|
||||
const handleNumberChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(e.target.value)
|
||||
}
|
||||
|
||||
const handleNumberBlur = () => {
|
||||
const next = parse(value)
|
||||
if (next == null) {
|
||||
if (!value.trim()) onChange('')
|
||||
return
|
||||
}
|
||||
onChange(format(next))
|
||||
}
|
||||
|
||||
const hintNumeric = useDiscrete && discreteValues
|
||||
? discreteValues[sliderValue]
|
||||
: sliderValue
|
||||
|
||||
const displayLabel = unset ? unsetLabel : formatDisplay(hintNumeric, false)
|
||||
|
||||
if (isLegacyText) {
|
||||
return (
|
||||
<div className="input-group metric-range-input metric-range-input--compact">
|
||||
<div className="metric-range-header">
|
||||
<label htmlFor={id}>{label}</label>
|
||||
</div>
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
placeholder={numberPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const hasSlider = useDiscrete || (min != null && max != null)
|
||||
|
||||
return (
|
||||
<div className="input-group metric-range-input metric-range-input--compact">
|
||||
<div className="metric-range-header">
|
||||
<label htmlFor={hideNumberInput ? undefined : id}>{label}</label>
|
||||
{hasSlider && (
|
||||
<span className="metric-range-value" aria-live="polite">
|
||||
{displayLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{hasSlider && (
|
||||
<div className="metric-range-control-row">
|
||||
<input
|
||||
type="range"
|
||||
className="tank-liter-slider metric-range-slider"
|
||||
min={sliderMin}
|
||||
max={sliderMax}
|
||||
step={1}
|
||||
value={sliderValue}
|
||||
onChange={handleSliderChange}
|
||||
disabled={disabled}
|
||||
aria-valuemin={sliderMin}
|
||||
aria-valuemax={sliderMax}
|
||||
aria-valuenow={sliderValue}
|
||||
aria-label={label}
|
||||
aria-valuetext={displayLabel}
|
||||
/>
|
||||
{!hideNumberInput && (
|
||||
<input
|
||||
id={id}
|
||||
type="number"
|
||||
className="input-text metric-range-number"
|
||||
value={unset ? '' : value.replace(/\s*hPa\s*$/i, '').replace(/°\s*$/, '')}
|
||||
onChange={handleNumberChange}
|
||||
onBlur={handleNumberBlur}
|
||||
disabled={disabled}
|
||||
min={numberMin}
|
||||
max={numberMax}
|
||||
step={numberStep}
|
||||
placeholder={numberPlaceholder}
|
||||
inputMode="decimal"
|
||||
aria-label={label}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,28 @@
|
||||
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'
|
||||
|
||||
export type ConfirmLeaveChoice = 'stay' | 'save' | 'discard'
|
||||
|
||||
interface DialogContextType {
|
||||
showAlert: (message: string, title?: string, confirmText?: string) => Promise<void>
|
||||
showConfirm: (message: string, title?: string, confirmText?: string, cancelText?: string) => Promise<boolean>
|
||||
showConfirmLeave: (
|
||||
message: string,
|
||||
title?: string,
|
||||
stayLabel?: string,
|
||||
saveLabel?: string,
|
||||
discardLabel?: string,
|
||||
options?: { showSave?: boolean }
|
||||
) => Promise<ConfirmLeaveChoice>
|
||||
}
|
||||
|
||||
const DialogContext = createContext<DialogContextType | undefined>(undefined)
|
||||
@@ -16,26 +36,36 @@ export function useDialog() {
|
||||
}
|
||||
|
||||
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 [title, setTitle] = useState('')
|
||||
const [message, setMessage] = useState('')
|
||||
const [type, setType] = useState<'alert' | 'confirm'>('alert')
|
||||
const [type, setType] = useState<'alert' | 'confirm' | 'confirm-leave'>('alert')
|
||||
const [confirmLabel, setConfirmLabel] = useState('OK')
|
||||
const [cancelLabel, setCancelLabel] = useState('Cancel')
|
||||
const [saveLabel, setSaveLabel] = useState('')
|
||||
const [discardLabel, setDiscardLabel] = useState('')
|
||||
const [showSaveOption, setShowSaveOption] = useState(false)
|
||||
|
||||
const resolveRef = useRef<((val: any) => void) | null>(null)
|
||||
const alertResolveRef = useRef<(() => void) | null>(null)
|
||||
const confirmResolveRef = useRef<((val: boolean) => void) | null>(null)
|
||||
const confirmLeaveResolveRef = useRef<((val: ConfirmLeaveChoice) => void) | null>(null)
|
||||
|
||||
const showAlert = useCallback((msg: string, headerTitle?: string, btnText?: string): Promise<void> => {
|
||||
setMessage(msg)
|
||||
setTitle(headerTitle || '')
|
||||
setType('alert')
|
||||
setConfirmLabel(btnText || 'OK')
|
||||
setConfirmLabel(btnText || t('dialog.ok'))
|
||||
setIsOpen(true)
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
resolveRef.current = resolve
|
||||
alertResolveRef.current = resolve
|
||||
})
|
||||
}, [])
|
||||
}, [t])
|
||||
|
||||
const showConfirm = useCallback((
|
||||
msg: string,
|
||||
@@ -46,53 +76,164 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
|
||||
setMessage(msg)
|
||||
setTitle(headerTitle || '')
|
||||
setType('confirm')
|
||||
setConfirmLabel(btnConfirm || 'Yes')
|
||||
setCancelLabel(btnCancel || 'No')
|
||||
setConfirmLabel(btnConfirm || t('dialog.yes'))
|
||||
setCancelLabel(btnCancel || t('dialog.no'))
|
||||
setIsOpen(true)
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
resolveRef.current = resolve
|
||||
confirmResolveRef.current = resolve
|
||||
})
|
||||
}, [t])
|
||||
|
||||
const showConfirmLeave = useCallback((
|
||||
msg: string,
|
||||
headerTitle?: string,
|
||||
btnStay?: string,
|
||||
btnSave?: string,
|
||||
btnDiscard?: string,
|
||||
options?: { showSave?: boolean }
|
||||
): Promise<ConfirmLeaveChoice> => {
|
||||
setMessage(msg)
|
||||
setTitle(headerTitle || '')
|
||||
setType('confirm-leave')
|
||||
setCancelLabel(btnStay || t('common.unsaved_changes_stay'))
|
||||
setSaveLabel(btnSave || t('common.unsaved_changes_save_leave'))
|
||||
setDiscardLabel(btnDiscard || t('common.unsaved_changes_discard'))
|
||||
setShowSaveOption(options?.showSave !== false)
|
||||
setIsOpen(true)
|
||||
|
||||
return new Promise<ConfirmLeaveChoice>((resolve) => {
|
||||
confirmLeaveResolveRef.current = resolve
|
||||
})
|
||||
}, [t])
|
||||
|
||||
const closeConfirmLeave = useCallback((choice: ConfirmLeaveChoice) => {
|
||||
setIsOpen(false)
|
||||
if (confirmLeaveResolveRef.current) {
|
||||
confirmLeaveResolveRef.current(choice)
|
||||
confirmLeaveResolveRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
setIsOpen(false)
|
||||
if (resolveRef.current) {
|
||||
resolveRef.current(type === 'confirm' ? true : undefined)
|
||||
resolveRef.current = null
|
||||
if (type === 'confirm' && confirmResolveRef.current) {
|
||||
confirmResolveRef.current(true)
|
||||
confirmResolveRef.current = null
|
||||
} else if (alertResolveRef.current) {
|
||||
alertResolveRef.current()
|
||||
alertResolveRef.current = null
|
||||
}
|
||||
}, [type])
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setIsOpen(false)
|
||||
if (resolveRef.current) {
|
||||
resolveRef.current(false)
|
||||
resolveRef.current = null
|
||||
if (type === 'confirm-leave') {
|
||||
closeConfirmLeave('stay')
|
||||
return
|
||||
}
|
||||
}, [])
|
||||
setIsOpen(false)
|
||||
if (confirmResolveRef.current) {
|
||||
confirmResolveRef.current(false)
|
||||
confirmResolveRef.current = null
|
||||
}
|
||||
}, [type, closeConfirmLeave])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
confirmRef.current?.focus()
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (type === 'confirm' || type === 'confirm-leave') handleCancel()
|
||||
else handleConfirm()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [isOpen, type, handleCancel, handleConfirm])
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({ showAlert, showConfirm }),
|
||||
[showAlert, showConfirm]
|
||||
() => ({ showAlert, showConfirm, showConfirmLeave }),
|
||||
[showAlert, showConfirm, showConfirmLeave]
|
||||
)
|
||||
|
||||
return (
|
||||
<DialogContext.Provider value={contextValue}>
|
||||
{children}
|
||||
{isOpen && (
|
||||
<div className="custom-dialog-overlay" onClick={type === 'alert' ? handleConfirm : undefined}>
|
||||
<div className="custom-dialog-card glass scale-in" onClick={(e) => e.stopPropagation()}>
|
||||
{title && <h3 className="custom-dialog-title">{title}</h3>}
|
||||
<p className="custom-dialog-message">{message}</p>
|
||||
<div
|
||||
className="custom-dialog-overlay"
|
||||
onClick={type === 'confirm' || type === 'confirm-leave' ? handleCancel : handleConfirm}
|
||||
>
|
||||
<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">
|
||||
{type === 'confirm' && (
|
||||
<button type="button" className="btn secondary" onClick={handleCancel} style={{ width: 'auto', padding: '8px 20px', margin: 0 }}>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
{type === 'confirm-leave' ? (
|
||||
<>
|
||||
<button
|
||||
ref={confirmRef}
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={handleCancel}
|
||||
style={{ width: 'auto', padding: '8px 20px', margin: 0 }}
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
{showSaveOption && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
onClick={() => closeConfirmLeave('save')}
|
||||
style={{ width: 'auto', padding: '8px 20px', margin: 0 }}
|
||||
>
|
||||
{saveLabel}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn danger"
|
||||
onClick={() => closeConfirmLeave('discard')}
|
||||
style={{ width: 'auto', padding: '8px 20px', margin: 0 }}
|
||||
>
|
||||
{discardLabel}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{type === 'confirm' && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={handleCancel}
|
||||
style={{ width: 'auto', padding: '8px 20px', margin: 0 }}
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
ref={confirmRef}
|
||||
type="button"
|
||||
className="btn primary"
|
||||
onClick={handleConfirm}
|
||||
style={{ width: 'auto', minWidth: '80px', padding: '8px 20px', margin: 0 }}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button type="button" className="btn primary" onClick={handleConfirm} style={{ width: 'auto', minWidth: '80px', padding: '8px 20px', margin: 0 }}>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</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
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Users, User, Plus, Trash2, Edit2, X, Camera, Save } from 'lucide-react'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import { resizeImageFile } from '../utils/resizeImageFile.js'
|
||||
import type { PersonData, PersonRole } from '../types/person.js'
|
||||
import { MAX_POOL_CREW_MEMBERS } from '../types/person.js'
|
||||
import {
|
||||
loadPersonPool,
|
||||
savePerson,
|
||||
deletePerson,
|
||||
filterSkippers,
|
||||
filterCrew,
|
||||
type DecryptedPerson
|
||||
} from '../services/personPool.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
|
||||
const emptyPerson = (role: PersonRole): PersonData => ({
|
||||
name: '',
|
||||
address: '',
|
||||
birthDate: '',
|
||||
phone: '',
|
||||
nationality: '',
|
||||
passportNumber: '',
|
||||
bloodType: '',
|
||||
allergies: '',
|
||||
diseases: '',
|
||||
role,
|
||||
photo: null
|
||||
})
|
||||
|
||||
export default function PersonPoolForm() {
|
||||
const { t } = useTranslation()
|
||||
const { showConfirm } = useDialog()
|
||||
const [people, setPeople] = useState<DecryptedPerson[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [formRole, setFormRole] = useState<PersonRole>('crew')
|
||||
const [form, setForm] = useState<PersonData>(emptyPerson('crew'))
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [photoError, setPhotoError] = useState<string | null>(null)
|
||||
const fileRef = React.useRef<HTMLInputElement>(null)
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
setPeople(await loadPersonPool())
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void reload()
|
||||
}, [reload])
|
||||
|
||||
const openAdd = (role: PersonRole) => {
|
||||
setEditingId(null)
|
||||
setFormRole(role)
|
||||
setForm(emptyPerson(role))
|
||||
setPhotoError(null)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const openEdit = (person: DecryptedPerson) => {
|
||||
setEditingId(person.payloadId)
|
||||
setFormRole(person.data.role)
|
||||
setForm({ ...person.data })
|
||||
setPhotoError(null)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!form.name.trim()) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const id = editingId ?? window.crypto.randomUUID()
|
||||
await savePerson(id, { ...form, role: formRole }, !editingId)
|
||||
setShowForm(false)
|
||||
trackPlausibleEvent(PlausibleEvents.CREW_SAVED, { role: formRole, context: 'person_pool' })
|
||||
await reload()
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.message === 'MAX_CREW') {
|
||||
setError(t('crew.max_crew'))
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save')
|
||||
}
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (
|
||||
!(await showConfirm(
|
||||
t('person_pool.delete_confirm'),
|
||||
t('person_pool.title'),
|
||||
t('logs.confirm_yes'),
|
||||
t('logs.confirm_no')
|
||||
))
|
||||
) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await deletePerson(id)
|
||||
await reload()
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete')
|
||||
}
|
||||
}
|
||||
|
||||
const skippers = filterSkippers(people)
|
||||
const crewList = filterCrew(people)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="tab-placeholder">
|
||||
<Users className="header-logo spin" size={48} />
|
||||
<p>{t('person_pool.loading')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderCard = (person: DecryptedPerson) => (
|
||||
<div key={person.payloadId} className="crew-member-card glass">
|
||||
<div className="crew-card-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
{person.data.photo ? (
|
||||
<img src={person.data.photo} alt="" className="crew-card-avatar" />
|
||||
) : (
|
||||
<div className="crew-card-avatar-placeholder">
|
||||
<User size={18} />
|
||||
</div>
|
||||
)}
|
||||
<h4>{person.data.name}</h4>
|
||||
</div>
|
||||
<div className="card-actions">
|
||||
<button type="button" className="btn-icon" onClick={() => openEdit(person)} title="Edit">
|
||||
<Edit2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon logout"
|
||||
onClick={() => void handleDelete(person.payloadId)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{person.data.phone && (
|
||||
<p className="help-text">
|
||||
<strong>{t('crew.phone')}:</strong> {person.data.phone}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<section className="form-card" data-tour="profile-crew-pool">
|
||||
<div className="form-header">
|
||||
<Users size={24} className="form-icon" />
|
||||
<h2>{t('person_pool.title')}</h2>
|
||||
</div>
|
||||
<p className="help-text mb-4">{t('person_pool.subtitle')}</p>
|
||||
{error && <div className="auth-error mb-4">{error}</div>}
|
||||
|
||||
<div className="section-title-bar mb-4">
|
||||
<h3>{t('person_pool.skippers_section')}</h3>
|
||||
{!showForm && (
|
||||
<button type="button" className="btn primary" style={{ width: 'auto', padding: '8px 16px' }} onClick={() => openAdd('skipper')}>
|
||||
<Plus size={16} />
|
||||
{t('person_pool.add_skipper')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{skippers.length === 0 ? (
|
||||
<p className="help-text mb-4">{t('person_pool.no_skippers')}</p>
|
||||
) : (
|
||||
<div className="crew-grid mb-6">{skippers.map(renderCard)}</div>
|
||||
)}
|
||||
|
||||
<div className="section-title-bar mb-4">
|
||||
<h3>{t('person_pool.crew_section')}</h3>
|
||||
{!showForm && crewList.length < MAX_POOL_CREW_MEMBERS && (
|
||||
<button type="button" className="btn primary" style={{ width: 'auto', padding: '8px 16px' }} onClick={() => openAdd('crew')}>
|
||||
<Plus size={16} />
|
||||
{t('person_pool.add_crew')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{crewList.length === 0 ? (
|
||||
<p className="help-text">{t('person_pool.no_crew')}</p>
|
||||
) : (
|
||||
<div className="crew-grid">{crewList.map(renderCard)}</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<form onSubmit={(e) => void handleSave(e)} className="member-editor-card glass mt-6">
|
||||
<div className="editor-header mb-4">
|
||||
<h3>
|
||||
{editingId
|
||||
? formRole === 'skipper'
|
||||
? t('person_pool.edit_skipper')
|
||||
: t('crew.edit_crew')
|
||||
: formRole === 'skipper'
|
||||
? t('person_pool.add_skipper')
|
||||
: t('crew.add_crew')}
|
||||
</h3>
|
||||
<button type="button" className="btn-icon" onClick={() => setShowForm(false)}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="form-grid">
|
||||
<div className="vessel-photo-wrapper">
|
||||
<div className="vessel-photo-preview" onClick={() => fileRef.current?.click()}>
|
||||
{form.photo ? (
|
||||
<img src={form.photo} alt="" className="vessel-photo" />
|
||||
) : (
|
||||
<div className="vessel-photo-placeholder">
|
||||
<User size={48} />
|
||||
</div>
|
||||
)}
|
||||
<div className="vessel-photo-overlay">
|
||||
<Camera size={24} />
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
void resizeImageFile(file)
|
||||
.then((photo) => setForm((f) => ({ ...f, photo })))
|
||||
.catch((err: unknown) => {
|
||||
setPhotoError(err instanceof Error ? err.message : 'Image error')
|
||||
})
|
||||
}}
|
||||
/>
|
||||
{photoError && <div className="auth-error mt-2">{photoError}</div>}
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label>{t('crew.name')} *</label>
|
||||
<input
|
||||
className="input-text"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label>{t('crew.address')}</label>
|
||||
<input
|
||||
className="input-text"
|
||||
value={form.address}
|
||||
onChange={(e) => setForm((f) => ({ ...f, address: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label>{t('crew.birthdate')}</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input-text"
|
||||
value={form.birthDate}
|
||||
onChange={(e) => setForm((f) => ({ ...f, birthDate: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label>{t('crew.phone')}</label>
|
||||
<input
|
||||
className="input-text"
|
||||
value={form.phone}
|
||||
onChange={(e) => setForm((f) => ({ ...f, phone: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label>{t('crew.nationality')}</label>
|
||||
<input
|
||||
className="input-text"
|
||||
value={form.nationality}
|
||||
onChange={(e) => setForm((f) => ({ ...f, nationality: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label>{t('crew.passport')}</label>
|
||||
<input
|
||||
className="input-text"
|
||||
value={form.passportNumber}
|
||||
onChange={(e) => setForm((f) => ({ ...f, passportNumber: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="editor-actions mt-4">
|
||||
<button type="submit" className="btn primary" disabled={saving || !form.name.trim()}>
|
||||
<Save size={18} />
|
||||
{t('crew.save_member')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -3,9 +3,9 @@ import { useTranslation } from 'react-i18next'
|
||||
import { db } from '../services/db.js'
|
||||
import { getActiveMasterKey } from '../services/auth.js'
|
||||
import { getLogbookKey } from '../services/logbookKeys.js'
|
||||
import { encryptJson, decryptJson } from '../services/crypto.js'
|
||||
import { syncLogbook } from '../services/sync.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { decryptJson } from '../services/crypto.js'
|
||||
import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.js'
|
||||
import { fileToCompressedJpegDataUrl } from '../utils/imageCompress.js'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import { Camera, Trash2 } from 'lucide-react'
|
||||
@@ -90,109 +90,30 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
|
||||
setUploading(true)
|
||||
setError(null)
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
const img = new Image()
|
||||
img.onload = async () => {
|
||||
try {
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) throw new Error('Could not get canvas context')
|
||||
|
||||
let width = img.width
|
||||
let height = img.height
|
||||
const MAX_WIDTH = 1280
|
||||
const MAX_HEIGHT = 720
|
||||
|
||||
// Calculate resizing conserving aspect ratio
|
||||
if (width > MAX_WIDTH || height > MAX_HEIGHT) {
|
||||
const ratio = Math.min(MAX_WIDTH / width, MAX_HEIGHT / height)
|
||||
width = Math.round(width * ratio)
|
||||
height = Math.round(height * ratio)
|
||||
}
|
||||
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
ctx.drawImage(img, 0, 0, width, height)
|
||||
|
||||
// Compress to JPEG, 70% quality
|
||||
const compressedBase64 = canvas.toDataURL('image/jpeg', 0.7)
|
||||
|
||||
// Encrypt
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
|
||||
const photoId = window.crypto.randomUUID()
|
||||
const photoPayload = {
|
||||
image: compressedBase64,
|
||||
caption: caption.trim()
|
||||
}
|
||||
|
||||
const encrypted = await encryptJson(photoPayload, masterKey)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// Store locally
|
||||
await db.photos.put({
|
||||
payloadId: photoId,
|
||||
entryId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
caption: '', // stored encrypted inside payload
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
// Queue for background sync
|
||||
await db.syncQueue.put({
|
||||
action: 'create',
|
||||
type: 'photo',
|
||||
payloadId: photoId,
|
||||
logbookId,
|
||||
data: JSON.stringify({
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
entryId
|
||||
}),
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
setCaption('')
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: 'logbook' })
|
||||
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
} catch (err: any) {
|
||||
console.error('Failed to process image:', err)
|
||||
setError(err.message || 'Failed to process image')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
img.src = event.target?.result as string
|
||||
try {
|
||||
const compressedBase64 = await fileToCompressedJpegDataUrl(file)
|
||||
await saveEntryPhoto({
|
||||
logbookId,
|
||||
entryId,
|
||||
imageDataUrl: compressedBase64,
|
||||
caption: caption.trim(),
|
||||
analyticsContext: 'logbook'
|
||||
})
|
||||
setCaption('')
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to process image:', err)
|
||||
setError(err instanceof Error ? err.message : 'Failed to process image')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
const handleDelete = async (photoId: string) => {
|
||||
if (await showConfirm(t('logs.photo_delete_confirm'), t('logs.photos_title'), t('logs.confirm_yes'), t('logs.confirm_no'))) {
|
||||
try {
|
||||
const now = new Date().toISOString()
|
||||
|
||||
await db.photos.delete(photoId)
|
||||
|
||||
await db.syncQueue.put({
|
||||
action: 'delete',
|
||||
type: 'photo',
|
||||
payloadId: photoId,
|
||||
logbookId,
|
||||
data: '',
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
} catch (err: any) {
|
||||
await deleteEntryPhoto(logbookId, photoId)
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to delete photo:', err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface ProfileAccordionSectionProps {
|
||||
id: string
|
||||
title: string
|
||||
icon?: ReactNode
|
||||
defaultOpen?: boolean
|
||||
/** When set, forces the section open (e.g. during onboarding tour). */
|
||||
forceOpen?: boolean
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export default function ProfileAccordionSection({
|
||||
id,
|
||||
title,
|
||||
icon,
|
||||
defaultOpen = false,
|
||||
forceOpen,
|
||||
children
|
||||
}: ProfileAccordionSectionProps) {
|
||||
const isOpen = forceOpen !== undefined ? forceOpen : defaultOpen
|
||||
|
||||
return (
|
||||
<details className="profile-accordion" open={isOpen || undefined} data-section={id}>
|
||||
<summary className="profile-accordion__summary">
|
||||
<span className="profile-accordion__title">
|
||||
{icon}
|
||||
<span>{title}</span>
|
||||
</span>
|
||||
<ChevronDown size={20} className="profile-accordion__chevron" aria-hidden="true" />
|
||||
</summary>
|
||||
<div className="profile-accordion__body">{children}</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
enableCollaboratorChangePush,
|
||||
fetchPushPrefs,
|
||||
getNotificationPermission,
|
||||
isPushSupported
|
||||
isPushSupported,
|
||||
preloadPushService
|
||||
} from '../services/pushNotifications.js'
|
||||
import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
@@ -28,6 +29,7 @@ export default function PushNotificationSettings() {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
void preloadPushService()
|
||||
try {
|
||||
const prefs = await fetchPushPrefs()
|
||||
setEnabled(prefs.collaboratorChangesEnabled)
|
||||
@@ -56,7 +58,8 @@ export default function PushNotificationSettings() {
|
||||
trackPlausibleEvent(PlausibleEvents.PUSH_DISABLED)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : t('profile.push_error')
|
||||
console.error('Failed to toggle push notifications:', err)
|
||||
const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err)
|
||||
showAlert(message)
|
||||
void loadPrefs()
|
||||
} finally {
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cycleAppLanguage, getNextLanguage, isGermanLocale } from '../utils/i18nLanguages.js'
|
||||
import { decryptJson } from '../services/crypto.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import VesselForm from './VesselForm.tsx'
|
||||
import CrewForm from './CrewForm.tsx'
|
||||
import LogbookVesselPicker from './LogbookVesselPicker.tsx'
|
||||
import LogbookCrewPicker from './LogbookCrewPicker.tsx'
|
||||
import type { LogbookVesselSelectionData } from '../types/vessel.js'
|
||||
import { emptyLogbookVesselSelection } from '../types/vessel.js'
|
||||
import type { LogbookCrewSelectionData } from '../types/person.js'
|
||||
import { emptyLogbookCrewSelection } from '../types/person.js'
|
||||
import { legacyCrewRecordsToLogbookSelection } from '../utils/personSnapshots.js'
|
||||
import type { PersonData } from '../types/person.js'
|
||||
import LogEntriesList from './LogEntriesList.tsx'
|
||||
import { Ship, Users, FileText, Lock, AlertCircle, Globe } from 'lucide-react'
|
||||
|
||||
@@ -30,7 +37,13 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
||||
// Logbook data states
|
||||
const [logbookTitle, setLogbookTitle] = useState('Logbook')
|
||||
const [yacht, setYacht] = useState<any>(null)
|
||||
const [crews, setCrews] = useState<any[]>([])
|
||||
const [logbookCrewSelection, setLogbookCrewSelection] = useState<LogbookCrewSelectionData>(
|
||||
emptyLogbookCrewSelection()
|
||||
)
|
||||
const [logbookVesselSelection, setLogbookVesselSelection] = useState<LogbookVesselSelectionData>(
|
||||
emptyLogbookVesselSelection()
|
||||
)
|
||||
const [legacyCrews, setLegacyCrews] = useState<any[]>([])
|
||||
const [entries, setEntries] = useState<any[]>([])
|
||||
const [photos, setPhotos] = useState<any[]>([])
|
||||
const [gpsTracks, setGpsTracks] = useState<any[]>([])
|
||||
@@ -48,9 +61,9 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
||||
const res = await fetch(`/api/collaboration/share-pull?token=${token}`)
|
||||
if (!res.ok) {
|
||||
if (res.status === 410) {
|
||||
throw new Error(i18n.language.startsWith('de') ? 'Dieser Freigabelink ist abgelaufen.' : 'This share link has expired.')
|
||||
throw new Error(isGermanLocale(i18n.language) ? 'Dieser Freigabelink ist abgelaufen.' : 'This share link has expired.')
|
||||
}
|
||||
throw new Error(i18n.language.startsWith('de') ? 'Fehler beim Laden des freigegebenen Logbuchs.' : 'Failed to fetch shared logbook data.')
|
||||
throw new Error(isGermanLocale(i18n.language) ? 'Fehler beim Laden des freigegebenen Logbuchs.' : 'Failed to fetch shared logbook data.')
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
@@ -70,18 +83,67 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
||||
}
|
||||
setYacht(decYacht)
|
||||
|
||||
// Decrypt Crews
|
||||
const decCrews = []
|
||||
if (data.crews) {
|
||||
for (const c of data.crews) {
|
||||
const dec = await decryptJson(c.encryptedData, c.iv, c.tag, keyBuffer)
|
||||
decCrews.push({
|
||||
payloadId: c.payloadId,
|
||||
data: dec
|
||||
if (data.logbookCrewSelection) {
|
||||
const decSel = await decryptJson(
|
||||
data.logbookCrewSelection.encryptedData,
|
||||
data.logbookCrewSelection.iv,
|
||||
data.logbookCrewSelection.tag,
|
||||
keyBuffer
|
||||
)
|
||||
if (decSel) {
|
||||
setLogbookCrewSelection({
|
||||
activeSkipperId: decSel.activeSkipperId ?? null,
|
||||
activeCrewIds: Array.isArray(decSel.activeCrewIds) ? decSel.activeCrewIds : [],
|
||||
snapshotsById:
|
||||
decSel.snapshotsById && typeof decSel.snapshotsById === 'object'
|
||||
? decSel.snapshotsById
|
||||
: {}
|
||||
})
|
||||
}
|
||||
}
|
||||
setCrews(decCrews)
|
||||
|
||||
if (data.logbookVesselSelection) {
|
||||
const decVessel = await decryptJson(
|
||||
data.logbookVesselSelection.encryptedData,
|
||||
data.logbookVesselSelection.iv,
|
||||
data.logbookVesselSelection.tag,
|
||||
keyBuffer
|
||||
)
|
||||
if (decVessel) {
|
||||
setLogbookVesselSelection({
|
||||
activeVesselId: decVessel.activeVesselId ?? null,
|
||||
vesselSnapshot: decVessel.vesselSnapshot ?? null
|
||||
})
|
||||
}
|
||||
} else if (decYacht) {
|
||||
const legacy = decYacht as Record<string, unknown>
|
||||
setLogbookVesselSelection({
|
||||
activeVesselId: 'legacy-yacht',
|
||||
vesselSnapshot: {
|
||||
id: 'legacy-yacht',
|
||||
name: typeof legacy.name === 'string' ? legacy.name : '',
|
||||
...legacy
|
||||
} as import('../types/vessel.js').VesselSnapshot
|
||||
})
|
||||
}
|
||||
|
||||
const decCrews: Array<{ payloadId: string; data: PersonData }> = []
|
||||
if (data.crews) {
|
||||
for (const c of data.crews) {
|
||||
const dec = await decryptJson(c.encryptedData, c.iv, c.tag, keyBuffer)
|
||||
if (dec) {
|
||||
decCrews.push({
|
||||
payloadId: c.payloadId,
|
||||
data: dec as PersonData
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
setLegacyCrews(decCrews)
|
||||
|
||||
if (!data.logbookCrewSelection && decCrews.length > 0) {
|
||||
setLogbookCrewSelection(legacyCrewRecordsToLogbookSelection(decCrews))
|
||||
}
|
||||
|
||||
// Decrypt Entries
|
||||
const decEntries = []
|
||||
@@ -136,15 +198,14 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
||||
}
|
||||
|
||||
const toggleLanguage = () => {
|
||||
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
|
||||
i18n.changeLanguage(nextLang)
|
||||
cycleAppLanguage(i18n)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="tab-placeholder" style={{ height: '100vh', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center' }}>
|
||||
<Ship className="header-logo spin" size={48} />
|
||||
<p>{i18n.language.startsWith('de') ? 'Lade freigegebenes Logbuch...' : 'Loading shared logbook...'}</p>
|
||||
<p>{isGermanLocale(i18n.language) ? 'Lade freigegebenes Logbuch...' : 'Loading shared logbook...'}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -153,10 +214,10 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
||||
return (
|
||||
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', padding: '20px', textAlign: 'center' }}>
|
||||
<AlertCircle size={48} style={{ color: '#ef4444', marginBottom: '16px' }} />
|
||||
<h2 style={{ color: '#f1f5f9', marginBottom: '8px' }}>{i18n.language.startsWith('de') ? 'Verbindungsfehler' : 'Access Error'}</h2>
|
||||
<h2 style={{ color: '#f1f5f9', marginBottom: '8px' }}>{isGermanLocale(i18n.language) ? 'Verbindungsfehler' : 'Access Error'}</h2>
|
||||
<p style={{ color: '#94a3b8', maxWidth: '400px', marginBottom: '24px' }}>{error}</p>
|
||||
<button className="btn primary" onClick={loadData} style={{ width: 'auto' }}>
|
||||
{i18n.language.startsWith('de') ? 'Erneut versuchen' : 'Retry'}
|
||||
{isGermanLocale(i18n.language) ? 'Erneut versuchen' : 'Retry'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
@@ -173,7 +234,7 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
||||
<h2>{logbookTitle}</h2>
|
||||
<p className="app-subtitle" style={{ color: '#10b981', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<Lock size={12} />
|
||||
<span>{i18n.language.startsWith('de') ? 'Schreibgeschützte Ansicht (Ende-zu-Ende verschlüsselt)' : 'Read-Only View (End-to-End Encrypted)'}</span>
|
||||
<span>{isGermanLocale(i18n.language) ? 'Schreibgeschützte Ansicht (Ende-zu-Ende verschlüsselt)' : 'Read-Only View (End-to-End Encrypted)'}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -181,7 +242,7 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
||||
<div className="header-actions">
|
||||
<button className="btn secondary" onClick={toggleLanguage} style={{ width: 'auto', padding: '6px 12px', fontSize: '13px' }}>
|
||||
<Globe size={14} style={{ marginRight: '4px' }} />
|
||||
{i18n.language.startsWith('de') ? 'English' : 'Deutsch'}
|
||||
{t(`languages.${getNextLanguage(i18n.language)}`)}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -226,18 +287,21 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
||||
)}
|
||||
|
||||
{activeTab === 'vessel' && (
|
||||
<VesselForm
|
||||
<LogbookVesselPicker
|
||||
logbookId="shared"
|
||||
readOnly={true}
|
||||
preloadedData={yacht}
|
||||
selectionOnly={true}
|
||||
preloadedSelection={logbookVesselSelection}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'crew' && (
|
||||
<CrewForm
|
||||
<LogbookCrewPicker
|
||||
logbookId="shared"
|
||||
readOnly={true}
|
||||
preloadedData={crews}
|
||||
selectionOnly={true}
|
||||
preloadedPool={legacyCrews.length > 0 ? legacyCrews : undefined}
|
||||
preloadedSelection={logbookCrewSelection}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -28,19 +28,19 @@ export default function RegistrationDisclaimer({
|
||||
className={`auth-card glass registration-disclaimer${variant === 'view' ? ' registration-disclaimer--modal' : ''}`}
|
||||
role="document"
|
||||
>
|
||||
{variant === 'view' && (
|
||||
<button
|
||||
type="button"
|
||||
className="registration-disclaimer__close"
|
||||
onClick={onDismiss}
|
||||
aria-label={t('disclaimer.close')}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
)}
|
||||
<div className="auth-header">
|
||||
<ScrollText className="auth-icon accent" size={48} />
|
||||
<h2>{t('disclaimer.title')}</h2>
|
||||
{variant === 'view' && (
|
||||
<button
|
||||
type="button"
|
||||
className="registration-disclaimer__close"
|
||||
onClick={onDismiss}
|
||||
aria-label={t('disclaimer.close')}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="registration-disclaimer__intro">{t('disclaimer.intro')}</p>
|
||||
|
||||
@@ -3,13 +3,15 @@ import { useTranslation } from 'react-i18next'
|
||||
import { Settings as SettingsIcon, Check, Users, Trash2, Copy, Link as LinkIcon } from 'lucide-react'
|
||||
import { ensureLogbookKey } from '../services/logbookKeys.js'
|
||||
import LogbookBackupPanel from './LogbookBackupPanel.tsx'
|
||||
import LinkQrCode from './LinkQrCode.tsx'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { apiFetch } from '../services/api.js'
|
||||
import {
|
||||
enableCollaboratorChangePush,
|
||||
isCollaboratorPushActive,
|
||||
isPushSupported
|
||||
isPushSupported,
|
||||
preloadPushService
|
||||
} from '../services/pushNotifications.js'
|
||||
import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
|
||||
|
||||
@@ -54,6 +56,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
||||
loadCollaborators()
|
||||
loadShareLink()
|
||||
}
|
||||
void preloadPushService()
|
||||
}, [logbookId])
|
||||
|
||||
const loadShareLink = async () => {
|
||||
@@ -190,7 +193,8 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
||||
trackPlausibleEvent(PlausibleEvents.PUSH_ENABLED)
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to enable push after invite:', err)
|
||||
await showAlert(err instanceof Error ? err.message : t('profile.push_error'))
|
||||
const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err)
|
||||
await showAlert(message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,23 +318,27 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
||||
</div>
|
||||
|
||||
{shareEnabled && shareLink && (
|
||||
<div className="input-group mb-4 copy-link-row">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={shareLink}
|
||||
className="input-text font-mono text-xs"
|
||||
style={{ flex: 1, padding: '10px' }}
|
||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={handleCopyShareLink}
|
||||
style={{ width: 'auto', padding: '10px' }}
|
||||
>
|
||||
{shareCopied ? <Check size={16} /> : <Copy size={16} />}
|
||||
</button>
|
||||
<div className="link-with-qr mb-4">
|
||||
<div className="input-group copy-link-row">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={shareLink}
|
||||
className="input-text font-mono text-xs"
|
||||
style={{ flex: 1, padding: '10px' }}
|
||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={handleCopyShareLink}
|
||||
style={{ width: 'auto', padding: '10px' }}
|
||||
title={t('settings.share_copy_btn')}
|
||||
>
|
||||
{shareCopied ? <Check size={16} /> : <Copy size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
<LinkQrCode value={shareLink} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -367,23 +375,27 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
||||
</div>
|
||||
|
||||
{inviteLink && (
|
||||
<div className="input-group mb-6 copy-link-row">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={inviteLink}
|
||||
className="input-text font-mono text-xs"
|
||||
style={{ flex: 1, padding: '10px' }}
|
||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={handleCopyInvite}
|
||||
style={{ width: 'auto', padding: '10px' }}
|
||||
>
|
||||
{inviteCopied ? <Check size={16} /> : <Copy size={16} />}
|
||||
</button>
|
||||
<div className="link-with-qr mb-6">
|
||||
<div className="input-group copy-link-row">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={inviteLink}
|
||||
className="input-text font-mono text-xs"
|
||||
style={{ flex: 1, padding: '10px' }}
|
||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={handleCopyInvite}
|
||||
style={{ width: 'auto', padding: '10px' }}
|
||||
title={t('settings.share_copy_btn')}
|
||||
>
|
||||
{inviteCopied ? <Check size={16} /> : <Copy size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
<LinkQrCode value={inviteLink} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -14,6 +14,11 @@ import {
|
||||
} from '../services/statsAggregation.js'
|
||||
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
|
||||
import { formatFuelPerMotorHour } from '../utils/fuelStats.js'
|
||||
import {
|
||||
loadLogbookEventSeries,
|
||||
type EventSeriesPoint,
|
||||
type EventSeriesSummary
|
||||
} from '../services/eventSeriesAggregation.js'
|
||||
|
||||
interface StatsDashboardProps {
|
||||
logbookId: string
|
||||
@@ -217,7 +222,62 @@ function PropulsionBreakdown({ totals }: { totals: StatsTotals }) {
|
||||
)
|
||||
}
|
||||
|
||||
function LogbookScopeView({ summary }: { summary: LogbookStatsSummary }) {
|
||||
function EventSeriesList({ title, points, emptyLabel }: { title: string; points: EventSeriesPoint[]; emptyLabel: string }) {
|
||||
if (points.length === 0) {
|
||||
return (
|
||||
<div className="stats-event-series-block">
|
||||
<h4 className="stats-section-subtitle">{title}</h4>
|
||||
<p className="stats-section-sub">{emptyLabel}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stats-event-series-block">
|
||||
<h4 className="stats-section-subtitle">{title}</h4>
|
||||
<ul className="stats-event-series-list">
|
||||
{points.map((point, idx) => (
|
||||
<li key={`${point.entryId}-${point.time}-${idx}`} className="stats-event-series-item">
|
||||
<span className="stats-event-series-when">
|
||||
{new Date(point.date).toLocaleDateString(undefined, { day: '2-digit', month: '2-digit' })}
|
||||
{' · '}
|
||||
{point.time}
|
||||
</span>
|
||||
<span className="stats-event-series-value">{point.summary}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EventSeriesPanel({ series }: { series: EventSeriesSummary }) {
|
||||
const { t } = useTranslation()
|
||||
const motorPoints = series.motor.map((point) => ({
|
||||
...point,
|
||||
summary: point.summary === 'start'
|
||||
? t('logs.live_motor_start')
|
||||
: t('logs.live_motor_stop')
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="member-editor-card glass mt-6">
|
||||
<h3 className="stats-section-title">{t('stats.event_series_title')}</h3>
|
||||
<p className="stats-section-sub">{t('stats.event_series_hint')}</p>
|
||||
<EventSeriesList title={t('stats.event_series_pressure')} points={series.pressure} emptyLabel={t('stats.event_series_empty')} />
|
||||
<EventSeriesList title={t('stats.event_series_wind')} points={series.wind} emptyLabel={t('stats.event_series_empty')} />
|
||||
<EventSeriesList title={t('stats.event_series_motor')} points={motorPoints} emptyLabel={t('stats.event_series_empty')} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LogbookScopeView({
|
||||
summary,
|
||||
eventSeries
|
||||
}: {
|
||||
summary: LogbookStatsSummary
|
||||
eventSeries: EventSeriesSummary | null
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { travelDays, routePorts, trackSegments, totals } = summary
|
||||
|
||||
@@ -313,6 +373,8 @@ function LogbookScopeView({ summary }: { summary: LogbookStatsSummary }) {
|
||||
<h3 className="stats-section-title">{t('stats.propulsion_title')}</h3>
|
||||
<PropulsionBreakdown totals={totals} />
|
||||
</div>
|
||||
|
||||
{eventSeries && <EventSeriesPanel series={eventSeries} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -323,18 +385,21 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [logbookStats, setLogbookStats] = useState<LogbookStatsSummary | null>(null)
|
||||
const [eventSeries, setEventSeries] = useState<EventSeriesSummary | null>(null)
|
||||
const [accountStats, setAccountStats] = useState<Awaited<ReturnType<typeof loadAccountStats>> | null>(null)
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [lb, acc] = await Promise.all([
|
||||
const [lb, acc, series] = await Promise.all([
|
||||
loadLogbookStats(logbookId, logbookTitle, true),
|
||||
loadAccountStats(false)
|
||||
loadAccountStats(false),
|
||||
loadLogbookEventSeries(logbookId)
|
||||
])
|
||||
setLogbookStats(lb)
|
||||
setAccountStats(acc)
|
||||
setEventSeries(series)
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to load statistics:', err)
|
||||
setError(err instanceof Error ? err.message : 'Failed to load statistics.')
|
||||
@@ -397,7 +462,7 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
|
||||
<p>{t('stats.loading')}</p>
|
||||
</div>
|
||||
) : scope === 'logbook' && logbookStats ? (
|
||||
<LogbookScopeView summary={logbookStats} />
|
||||
<LogbookScopeView summary={logbookStats} eventSeries={eventSeries} />
|
||||
) : scope === 'account' && accountStats ? (
|
||||
<>
|
||||
<TotalsGrid totals={accountStats.totals} />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Anchor,
|
||||
Gauge,
|
||||
Sailboat,
|
||||
Ship,
|
||||
Timer,
|
||||
Share2,
|
||||
Calendar,
|
||||
@@ -30,6 +31,10 @@ import {
|
||||
} from 'lucide-react'
|
||||
import AccountDangerZone from './AccountDangerZone.tsx'
|
||||
import UserProfilePreferences from './UserProfilePreferences.tsx'
|
||||
import PersonPoolForm from './PersonPoolForm.tsx'
|
||||
import VesselPoolForm from './VesselPoolForm.tsx'
|
||||
import ProfileAccordionSection from './ProfileAccordionSection.tsx'
|
||||
import { useAppTour } from '../context/AppTourContext.tsx'
|
||||
import BetaBadge from './BetaBadge.tsx'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import {
|
||||
@@ -136,6 +141,11 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
||||
connStatusClassName
|
||||
} = useSyncIndicator()
|
||||
|
||||
const { isActive: tourActive, currentStepId: tourStepId } = useAppTour()
|
||||
const fleetSectionTourOpen =
|
||||
tourActive &&
|
||||
(tourStepId === 'profile_vessel_pool' || tourStepId === 'profile_crew_pool')
|
||||
|
||||
const sharedLogbookCount = useLiveQuery(
|
||||
() => db.logbooks.filter((lb) => lb.isShared === 1).count(),
|
||||
[]
|
||||
@@ -443,8 +453,14 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
||||
</section>
|
||||
) : profile ? (
|
||||
<>
|
||||
<ProfileAccordionSection
|
||||
id="account"
|
||||
title={t('profile.sections.account')}
|
||||
icon={<User size={20} aria-hidden="true" />}
|
||||
defaultOpen
|
||||
>
|
||||
<div data-tour="profile-preferences">
|
||||
<section className="form-card">
|
||||
<section className="form-card profile-accordion-inner-card">
|
||||
<div className="form-header">
|
||||
<User size={24} className="form-icon" />
|
||||
<h2>{t('profile.identity_title')}</h2>
|
||||
@@ -486,8 +502,25 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
||||
|
||||
<UserProfilePreferences userId={profile.userId} />
|
||||
</div>
|
||||
</ProfileAccordionSection>
|
||||
|
||||
<section className="member-editor-card glass">
|
||||
<ProfileAccordionSection
|
||||
id="fleet"
|
||||
title={t('profile.sections.fleet')}
|
||||
icon={<Ship size={20} aria-hidden="true" />}
|
||||
defaultOpen
|
||||
forceOpen={fleetSectionTourOpen ? true : undefined}
|
||||
>
|
||||
<VesselPoolForm />
|
||||
<PersonPoolForm />
|
||||
</ProfileAccordionSection>
|
||||
|
||||
<ProfileAccordionSection
|
||||
id="security"
|
||||
title={t('profile.sections.security')}
|
||||
icon={<Shield size={20} aria-hidden="true" />}
|
||||
>
|
||||
<section className="member-editor-card glass profile-accordion-inner-card">
|
||||
<div className="profile-section-header">
|
||||
<Shield size={20} />
|
||||
<h3>{t('profile.security_title')}</h3>
|
||||
@@ -726,7 +759,14 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="form-card profile-stats-section">
|
||||
</ProfileAccordionSection>
|
||||
|
||||
<ProfileAccordionSection
|
||||
id="stats"
|
||||
title={t('profile.sections.stats')}
|
||||
icon={<BarChart2 size={20} aria-hidden="true" />}
|
||||
>
|
||||
<section className="form-card profile-stats-section profile-accordion-inner-card">
|
||||
<div className="form-header">
|
||||
<BarChart2 size={24} className="form-icon" />
|
||||
<div>
|
||||
@@ -788,8 +828,14 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</ProfileAccordionSection>
|
||||
|
||||
<AccountDangerZone className="mt-6" />
|
||||
<ProfileAccordionSection
|
||||
id="danger"
|
||||
title={t('profile.sections.danger')}
|
||||
>
|
||||
<AccountDangerZone className="profile-accordion-inner-card" />
|
||||
</ProfileAccordionSection>
|
||||
</>
|
||||
) : null}
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Ship, Camera, Trash2, Plus, X } from 'lucide-react'
|
||||
import type { VesselFormInputs } from '../utils/vesselFormUtils.js'
|
||||
|
||||
export interface VesselDataFieldsProps {
|
||||
inputs: VesselFormInputs
|
||||
onChange: (next: VesselFormInputs) => void
|
||||
readOnly?: boolean
|
||||
saving?: boolean
|
||||
newSailName: string
|
||||
onNewSailNameChange: (value: string) => void
|
||||
onAddSail: () => void
|
||||
onRemoveSail: (index: number) => void
|
||||
photoError?: string | null
|
||||
onPhotoChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
onRemovePhoto: () => void
|
||||
fileInputRef: React.RefObject<HTMLInputElement | null>
|
||||
}
|
||||
|
||||
export default function VesselDataFields({
|
||||
inputs,
|
||||
onChange,
|
||||
readOnly = false,
|
||||
saving = false,
|
||||
newSailName,
|
||||
onNewSailNameChange,
|
||||
onAddSail,
|
||||
onRemoveSail,
|
||||
photoError,
|
||||
onPhotoChange,
|
||||
onRemovePhoto,
|
||||
fileInputRef
|
||||
}: VesselDataFieldsProps) {
|
||||
const { t } = useTranslation()
|
||||
const set = (patch: Partial<VesselFormInputs>) => onChange({ ...inputs, ...patch })
|
||||
|
||||
const triggerFileInput = () => {
|
||||
if (!readOnly) fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="form-grid">
|
||||
<div className="vessel-photo-wrapper">
|
||||
<div
|
||||
className="vessel-photo-preview"
|
||||
onClick={triggerFileInput}
|
||||
style={{ cursor: readOnly ? 'default' : 'pointer' }}
|
||||
>
|
||||
{inputs.photo ? (
|
||||
<img src={inputs.photo} alt={inputs.name || 'Vessel'} className="vessel-photo" />
|
||||
) : (
|
||||
<div className="vessel-photo-placeholder">
|
||||
<Ship size={48} className="placeholder-icon" />
|
||||
</div>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<div className="vessel-photo-overlay">
|
||||
<Camera size={24} />
|
||||
<span>{inputs.photo ? t('vessel.photo_change') : t('vessel.photo_add')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="vessel-photo-actions">
|
||||
<button type="button" className="btn secondary btn-sm" onClick={triggerFileInput} disabled={saving}>
|
||||
<Camera size={16} />
|
||||
{inputs.photo ? t('vessel.photo_change') : t('vessel.photo_add')}
|
||||
</button>
|
||||
{inputs.photo && (
|
||||
<button type="button" className="btn danger btn-sm" onClick={onRemovePhoto} disabled={saving}>
|
||||
<Trash2 size={16} />
|
||||
{t('vessel.photo_delete')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={onPhotoChange}
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
{photoError && <div className="auth-error mt-2">{photoError}</div>}
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.name')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={inputs.name}
|
||||
onChange={(e) => set({ name: e.target.value })}
|
||||
disabled={saving || readOnly}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.type')}</label>
|
||||
<select
|
||||
className="input-text"
|
||||
value={inputs.vesselType}
|
||||
onChange={(e) => set({ vesselType: e.target.value })}
|
||||
disabled={saving || readOnly}
|
||||
>
|
||||
<option value="">{t('vessel.type_unset')}</option>
|
||||
<option value="sailing">{t('vessel.type_sailing')}</option>
|
||||
<option value="motor">{t('vessel.type_motor')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.length_m')}</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="input-text"
|
||||
value={inputs.lengthM}
|
||||
onChange={(e) => set({ lengthM: e.target.value })}
|
||||
disabled={saving || readOnly}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.draft_m')}</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="input-text"
|
||||
value={inputs.draftM}
|
||||
onChange={(e) => set({ draftM: e.target.value })}
|
||||
disabled={saving || readOnly}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.air_draft_m')}</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="input-text"
|
||||
value={inputs.airDraftM}
|
||||
onChange={(e) => set({ airDraftM: e.target.value })}
|
||||
disabled={saving || readOnly}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.port')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={inputs.homePort}
|
||||
onChange={(e) => set({ homePort: e.target.value })}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.owner')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={inputs.owner}
|
||||
onChange={(e) => set({ owner: e.target.value })}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.charter')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={inputs.charterCompany}
|
||||
onChange={(e) => set({ charterCompany: e.target.value })}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.registration')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={inputs.registrationNumber}
|
||||
onChange={(e) => set({ registrationNumber: e.target.value })}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.callsign')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={inputs.callSign}
|
||||
onChange={(e) => set({ callSign: e.target.value })}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.atis')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={inputs.atis}
|
||||
onChange={(e) => set({ atis: e.target.value })}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.mmsi')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={inputs.mmsi}
|
||||
onChange={(e) => set({ mmsi: e.target.value })}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="vessel-tanks-section">
|
||||
<h3>{t('vessel.tanks_section')}</h3>
|
||||
<p className="vessel-tanks-help">{t('vessel.tanks_help')}</p>
|
||||
<div className="vessel-tanks-grid">
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.freshwater_capacity_l')}</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="input-text"
|
||||
value={inputs.freshwaterCapacityL}
|
||||
onChange={(e) => set({ freshwaterCapacityL: e.target.value })}
|
||||
disabled={saving || readOnly}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.fuel_capacity_l')}</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="input-text"
|
||||
value={inputs.fuelCapacityL}
|
||||
onChange={(e) => set({ fuelCapacityL: e.target.value })}
|
||||
disabled={saving || readOnly}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.greywater_capacity_l')}</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="input-text"
|
||||
value={inputs.greywaterCapacityL}
|
||||
onChange={(e) => set({ greywaterCapacityL: e.target.value })}
|
||||
disabled={saving || readOnly}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sails-section">
|
||||
<h3>{t('vessel.sails_list')}</h3>
|
||||
<p className="help-text">{t('vessel.sails_help')}</p>
|
||||
<div className="sails-badges-grid">
|
||||
{inputs.sails.length === 0 ? (
|
||||
<span className="no-sails-msg">{t('vessel.no_sails')}</span>
|
||||
) : (
|
||||
inputs.sails.map((sail, idx) => (
|
||||
<span key={idx} className="sail-badge">
|
||||
{sail}
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
className="remove-btn"
|
||||
onClick={() => onRemoveSail(idx)}
|
||||
disabled={saving}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="add-sail-form">
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
placeholder={t('vessel.sail_name_placeholder')}
|
||||
value={newSailName}
|
||||
onChange={(e) => onNewSailNameChange(e.target.value)}
|
||||
disabled={saving}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
onAddSail()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={onAddSail}
|
||||
disabled={saving || !newSailName.trim()}
|
||||
style={{ width: 'auto' }}
|
||||
>
|
||||
<Plus size={16} />
|
||||
{t('vessel.add_sail')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Ship, Plus, Trash2, Edit2, X, Save } from 'lucide-react'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import VesselDataFields from './VesselDataFields.tsx'
|
||||
import type { VesselFormInputs } from '../utils/vesselFormUtils.js'
|
||||
import { parseVesselFormInputs, vesselDataToFormInputs } from '../utils/vesselFormUtils.js'
|
||||
import { emptyVesselData } from '../types/vessel.js'
|
||||
import { loadVesselPool, saveVessel, deleteVessel, type DecryptedVessel } from '../services/vesselPool.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
|
||||
export default function VesselPoolForm() {
|
||||
const { t } = useTranslation()
|
||||
const { showConfirm } = useDialog()
|
||||
const [vessels, setVessels] = useState<DecryptedVessel[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [inputs, setInputs] = useState<VesselFormInputs>(vesselDataToFormInputs(emptyVesselData()))
|
||||
const [newSailName, setNewSailName] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [photoError, setPhotoError] = useState<string | null>(null)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
setVessels(await loadVesselPool())
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void reload()
|
||||
}, [reload])
|
||||
|
||||
const openAdd = () => {
|
||||
setEditingId(null)
|
||||
setInputs(vesselDataToFormInputs(emptyVesselData()))
|
||||
setNewSailName('')
|
||||
setPhotoError(null)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const openEdit = (vessel: DecryptedVessel) => {
|
||||
setEditingId(vessel.payloadId)
|
||||
setInputs(vesselDataToFormInputs(vessel.data))
|
||||
setNewSailName('')
|
||||
setPhotoError(null)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
const handleAddSail = () => {
|
||||
const trimmed = newSailName.trim()
|
||||
if (trimmed && !inputs.sails.includes(trimmed)) {
|
||||
setInputs((prev) => ({ ...prev, sails: [...prev.sails, trimmed] }))
|
||||
}
|
||||
setNewSailName('')
|
||||
}
|
||||
|
||||
const handlePhotoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setPhotoError(null)
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
try {
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) throw new Error('Could not get canvas context')
|
||||
let width = img.width
|
||||
let height = img.height
|
||||
const MAX_WIDTH = 800
|
||||
const MAX_HEIGHT = 600
|
||||
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)
|
||||
setInputs((prev) => ({ ...prev, photo: canvas.toDataURL('image/jpeg', 0.7) }))
|
||||
} catch (err: unknown) {
|
||||
setPhotoError(err instanceof Error ? err.message : 'Failed to process image')
|
||||
}
|
||||
}
|
||||
img.onerror = () => setPhotoError('Invalid image file')
|
||||
img.src = event.target?.result as string
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!inputs.name.trim()) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = parseVesselFormInputs(inputs)
|
||||
const id = editingId ?? window.crypto.randomUUID()
|
||||
await saveVessel(id, data, !editingId)
|
||||
setShowForm(false)
|
||||
trackPlausibleEvent(PlausibleEvents.VESSEL_SAVED, { context: 'vessel_pool' })
|
||||
await reload()
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.message === 'MAX_VESSELS') {
|
||||
setError(t('vessel_pool.max_vessels'))
|
||||
} else if (err instanceof Error && err.message === 'invalid_metric') {
|
||||
setError(t('vessel.invalid_metric'))
|
||||
} else if (err instanceof Error && err.message === 'invalid_tank_liters') {
|
||||
setError(t('vessel.invalid_tank_liters'))
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save')
|
||||
}
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (
|
||||
!(await showConfirm(
|
||||
t('vessel_pool.delete_confirm'),
|
||||
t('vessel_pool.title'),
|
||||
t('logs.confirm_yes'),
|
||||
t('logs.confirm_no')
|
||||
))
|
||||
) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await deleteVessel(id)
|
||||
await reload()
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="tab-placeholder">
|
||||
<Ship className="header-logo spin" size={48} />
|
||||
<p>{t('vessel_pool.loading')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-tour="profile-vessel-pool">
|
||||
<div className="section-title-bar mb-4">
|
||||
<h3>{t('vessel_pool.section_title')}</h3>
|
||||
{!showForm && (
|
||||
<button type="button" className="btn primary" style={{ width: 'auto', padding: '8px 16px' }} onClick={openAdd}>
|
||||
<Plus size={16} />
|
||||
{t('vessel_pool.add_vessel')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="help-text mb-4">{t('vessel_pool.subtitle')}</p>
|
||||
{error && <div className="auth-error mb-4">{error}</div>}
|
||||
|
||||
{vessels.length === 0 ? (
|
||||
<p className="help-text mb-4">{t('vessel_pool.no_vessels')}</p>
|
||||
) : (
|
||||
<div className="crew-grid mb-6">
|
||||
{vessels.map((v) => (
|
||||
<div key={v.payloadId} className="crew-member-card glass">
|
||||
<div className="crew-card-header">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
{v.data.photo ? (
|
||||
<img src={v.data.photo} alt="" className="crew-card-avatar" />
|
||||
) : (
|
||||
<div className="crew-card-avatar-placeholder">
|
||||
<Ship size={18} />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h4>{v.data.name}</h4>
|
||||
{v.data.homePort && <p className="help-text">{v.data.homePort}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-actions">
|
||||
<button type="button" className="btn-icon" onClick={() => openEdit(v)}>
|
||||
<Edit2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon logout"
|
||||
onClick={() => void handleDelete(v.payloadId)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<form onSubmit={(e) => void handleSave(e)} className="member-editor-card glass">
|
||||
<div className="editor-header mb-4">
|
||||
<h3>{editingId ? t('vessel_pool.edit_vessel') : t('vessel_pool.add_vessel')}</h3>
|
||||
<button type="button" className="btn-icon" onClick={() => setShowForm(false)}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<VesselDataFields
|
||||
inputs={inputs}
|
||||
onChange={setInputs}
|
||||
saving={saving}
|
||||
newSailName={newSailName}
|
||||
onNewSailNameChange={setNewSailName}
|
||||
onAddSail={handleAddSail}
|
||||
onRemoveSail={(idx) =>
|
||||
setInputs((prev) => ({ ...prev, sails: prev.sails.filter((_, i) => i !== idx) }))
|
||||
}
|
||||
photoError={photoError}
|
||||
onPhotoChange={handlePhotoChange}
|
||||
onRemovePhoto={() => {
|
||||
setInputs((prev) => ({ ...prev, photo: null }))
|
||||
if (fileRef.current) fileRef.current.value = ''
|
||||
}}
|
||||
fileInputRef={fileRef}
|
||||
/>
|
||||
<div className="form-actions mt-4">
|
||||
<button type="submit" className="btn primary" disabled={saving || !inputs.name.trim()}>
|
||||
<Save size={18} />
|
||||
{saving ? t('vessel.saving') : t('vessel.save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -17,7 +17,13 @@ describe('AppTourContext step order', () => {
|
||||
expect(profileIndex).toBeGreaterThan(FULL_STEP_ORDER.indexOf('nav_feedback'))
|
||||
expect(prefsIndex).toBe(profileIndex + 1)
|
||||
expect(finishIndex).toBe(prefsIndex + 1)
|
||||
expect(FULL_STEP_ORDER).toHaveLength(12)
|
||||
expect(FULL_STEP_ORDER).toContain('profile_vessel_pool')
|
||||
expect(FULL_STEP_ORDER).toContain('profile_crew_pool')
|
||||
expect(FULL_STEP_ORDER).toContain('nav_logbook_crew')
|
||||
expect(FULL_STEP_ORDER.indexOf('profile_vessel_pool')).toBeLessThan(
|
||||
FULL_STEP_ORDER.indexOf('profile_crew_pool')
|
||||
)
|
||||
expect(FULL_STEP_ORDER).toHaveLength(14)
|
||||
})
|
||||
|
||||
it('excludes profile, stats and feedback from demo tour', () => {
|
||||
|
||||
@@ -26,7 +26,9 @@ export type TourStepId =
|
||||
| 'entry_open'
|
||||
| 'entry_track'
|
||||
| 'nav_vessel'
|
||||
| 'nav_crew'
|
||||
| 'profile_vessel_pool'
|
||||
| 'profile_crew_pool'
|
||||
| 'nav_logbook_crew'
|
||||
| 'nav_stats'
|
||||
| 'nav_feedback'
|
||||
| 'nav_profile'
|
||||
@@ -71,7 +73,9 @@ export const FULL_STEP_ORDER: TourStepId[] = [
|
||||
'entry_open',
|
||||
'entry_track',
|
||||
'nav_vessel',
|
||||
'nav_crew',
|
||||
'profile_vessel_pool',
|
||||
'profile_crew_pool',
|
||||
'nav_logbook_crew',
|
||||
'nav_stats',
|
||||
'nav_feedback',
|
||||
'nav_profile',
|
||||
@@ -81,6 +85,7 @@ export const FULL_STEP_ORDER: TourStepId[] = [
|
||||
|
||||
/** Public demo has no stats/feedback/profile UI — skip those steps. */
|
||||
export const DEMO_EXCLUDED_STEPS: TourStepId[] = [
|
||||
'profile_crew_pool',
|
||||
'nav_stats',
|
||||
'nav_feedback',
|
||||
'nav_profile',
|
||||
@@ -97,7 +102,7 @@ const LOGBOOK_TOUR_STEPS = new Set<TourStepId>([
|
||||
'entry_open',
|
||||
'entry_track',
|
||||
'nav_vessel',
|
||||
'nav_crew',
|
||||
'nav_logbook_crew',
|
||||
'nav_stats',
|
||||
'nav_feedback'
|
||||
])
|
||||
@@ -112,7 +117,9 @@ const TARGET_BY_STEP: Partial<Record<TourStepId, string>> = {
|
||||
entry_open: '[data-tour="entry-first"]',
|
||||
entry_track: '[data-tour="entry-track"]',
|
||||
nav_vessel: '[data-tour="nav-vessel"]',
|
||||
nav_crew: '[data-tour="nav-crew"]',
|
||||
profile_vessel_pool: '[data-tour="profile-vessel-pool"]',
|
||||
profile_crew_pool: '[data-tour="profile-crew-pool"]',
|
||||
nav_logbook_crew: '[data-tour="nav-logbook-crew"]',
|
||||
nav_stats: '[data-tour="stats-dashboard"]',
|
||||
nav_feedback: '[data-tour="feedback-form"]',
|
||||
nav_profile: '[data-tour="nav-profile"]',
|
||||
@@ -127,7 +134,14 @@ export function tourStepOpensEntry(stepId: TourStepId): boolean {
|
||||
export function getTourTargetDelay(stepId: TourStepId): number {
|
||||
if (stepId === 'entry_track') return 400
|
||||
if (stepId === 'nav_feedback') return 180
|
||||
if (stepId === 'nav_profile' || stepId === 'profile_preferences') return 250
|
||||
if (
|
||||
stepId === 'nav_profile' ||
|
||||
stepId === 'profile_preferences' ||
|
||||
stepId === 'profile_vessel_pool' ||
|
||||
stepId === 'profile_crew_pool'
|
||||
) {
|
||||
return 250
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -183,8 +197,15 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
||||
nav.setSelectedEntryId(null)
|
||||
nav.setActiveTab('vessel')
|
||||
}
|
||||
if (stepId === 'nav_crew') {
|
||||
if (stepId === 'profile_vessel_pool' || stepId === 'profile_crew_pool') {
|
||||
nav.setSelectedEntryId(null)
|
||||
nav.setLogbookActive(false)
|
||||
nav.setProfileOpen(true)
|
||||
}
|
||||
if (stepId === 'nav_logbook_crew') {
|
||||
nav.setSelectedEntryId(null)
|
||||
nav.setProfileOpen(false)
|
||||
nav.setLogbookActive(true)
|
||||
nav.setActiveTab('crew')
|
||||
}
|
||||
if (stepId === 'nav_stats') {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useDialog } from '../components/ModalDialog.tsx'
|
||||
|
||||
interface UnsavedChangesContextValue {
|
||||
setDirty: (source: string, dirty: boolean) => void
|
||||
registerSaveHandler: (source: string, handler: (() => Promise<void>) | null) => void
|
||||
confirmLeave: () => Promise<boolean>
|
||||
}
|
||||
|
||||
@@ -19,23 +20,51 @@ const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(n
|
||||
|
||||
export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
|
||||
const { t } = useTranslation()
|
||||
const { showConfirm } = useDialog()
|
||||
const { showConfirmLeave, showAlert } = useDialog()
|
||||
const dirtySources = useRef(new Set<string>())
|
||||
const saveHandlers = useRef(new Map<string, () => Promise<void>>())
|
||||
|
||||
const setDirty = useCallback((source: string, dirty: boolean) => {
|
||||
if (dirty) dirtySources.current.add(source)
|
||||
else dirtySources.current.delete(source)
|
||||
}, [])
|
||||
|
||||
const registerSaveHandler = useCallback((source: string, handler: (() => Promise<void>) | null) => {
|
||||
if (handler) saveHandlers.current.set(source, handler)
|
||||
else saveHandlers.current.delete(source)
|
||||
}, [])
|
||||
|
||||
const confirmLeave = useCallback(async (): Promise<boolean> => {
|
||||
if (dirtySources.current.size === 0) return true
|
||||
return showConfirm(
|
||||
|
||||
const canSave = [...dirtySources.current].some((source) => saveHandlers.current.has(source))
|
||||
const choice = await showConfirmLeave(
|
||||
t('common.unsaved_changes_message'),
|
||||
t('common.unsaved_changes_title'),
|
||||
t('common.unsaved_changes_leave'),
|
||||
t('common.unsaved_changes_stay')
|
||||
t('common.unsaved_changes_stay'),
|
||||
t('common.unsaved_changes_save_leave'),
|
||||
t('common.unsaved_changes_discard'),
|
||||
{ showSave: canSave }
|
||||
)
|
||||
}, [showConfirm, t])
|
||||
|
||||
if (choice === 'stay') return false
|
||||
if (choice === 'discard') return true
|
||||
|
||||
const handlers = [...dirtySources.current]
|
||||
.map((source) => saveHandlers.current.get(source))
|
||||
.filter((handler): handler is () => Promise<void> => handler != null)
|
||||
|
||||
try {
|
||||
for (const handler of handlers) {
|
||||
await handler()
|
||||
}
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('Failed to save before leaving:', err)
|
||||
await showAlert(t('errors.save_failed'))
|
||||
return false
|
||||
}
|
||||
}, [showConfirmLeave, showAlert, t])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
@@ -47,7 +76,10 @@ export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
|
||||
return () => window.removeEventListener('beforeunload', handler)
|
||||
}, [])
|
||||
|
||||
const value = useMemo(() => ({ setDirty, confirmLeave }), [setDirty, confirmLeave])
|
||||
const value = useMemo(
|
||||
() => ({ setDirty, registerSaveHandler, confirmLeave }),
|
||||
[setDirty, registerSaveHandler, confirmLeave]
|
||||
)
|
||||
|
||||
return (
|
||||
<UnsavedChangesContext.Provider value={value}>
|
||||
@@ -65,13 +97,26 @@ export function useUnsavedChangesContext(): UnsavedChangesContextValue {
|
||||
}
|
||||
|
||||
/** Register a form/view as having unsaved changes (cleared automatically on unmount). */
|
||||
export function useRegisterUnsavedChanges(source: string, isDirty: boolean) {
|
||||
const { setDirty, confirmLeave } = useUnsavedChangesContext()
|
||||
export function useRegisterUnsavedChanges(
|
||||
source: string,
|
||||
isDirty: boolean,
|
||||
onSave?: () => Promise<void>
|
||||
) {
|
||||
const { setDirty, registerSaveHandler, confirmLeave } = useUnsavedChangesContext()
|
||||
|
||||
useEffect(() => {
|
||||
setDirty(source, isDirty)
|
||||
return () => setDirty(source, false)
|
||||
}, [source, isDirty, setDirty])
|
||||
|
||||
useEffect(() => {
|
||||
if (!onSave) {
|
||||
registerSaveHandler(source, null)
|
||||
return
|
||||
}
|
||||
registerSaveHandler(source, onSave)
|
||||
return () => registerSaveHandler(source, null)
|
||||
}, [source, onSave, registerSaveHandler])
|
||||
|
||||
return { confirmLeave }
|
||||
}
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useRegisterSW } from 'virtual:pwa-register/react'
|
||||
import { markReloadAttempt, recentlyAttemptedReload } from '../services/pwaStartup.js'
|
||||
import {
|
||||
forcePwaRecovery,
|
||||
markReloadAttempt,
|
||||
recentlyAttemptedReload,
|
||||
triggerServiceWorkerUpdate
|
||||
} from '../services/pwaStartup.js'
|
||||
import { isDeployedVersionNewer } from '../services/pwaVersion.js'
|
||||
|
||||
const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000
|
||||
const UPDATE_CHECK_INTERVAL_MS = 15 * 60 * 1000
|
||||
const UPDATE_SUPPRESS_KEY = 'pwa_update_suppress_until'
|
||||
const UPDATE_SUPPRESS_MS = 30_000
|
||||
const UPDATE_DISMISS_SUPPRESS_MS = 60 * 60 * 1000
|
||||
const UPDATE_RELOAD_FALLBACK_MS = 2000
|
||||
const UPDATE_DISMISS_SUPPRESS_MS = 15 * 60 * 1000
|
||||
const UPDATE_RELOAD_FALLBACK_MS = 2_000
|
||||
const UPDATE_HARD_RECOVERY_MS = 5_000
|
||||
|
||||
function isUpdateSuppressed(): boolean {
|
||||
const suppressUntil = Number(sessionStorage.getItem(UPDATE_SUPPRESS_KEY) || '0')
|
||||
@@ -21,24 +28,40 @@ function clearUpdateSuppression(): void {
|
||||
sessionStorage.removeItem(UPDATE_SUPPRESS_KEY)
|
||||
}
|
||||
|
||||
function scheduleUpdateChecks(registration: ServiceWorkerRegistration): () => void {
|
||||
function scheduleUpdateChecks(
|
||||
registration: ServiceWorkerRegistration,
|
||||
onOutdated: () => void
|
||||
): () => void {
|
||||
const checkForUpdate = () => {
|
||||
if (isUpdateSuppressed()) return
|
||||
registration.update().catch(() => {})
|
||||
void isDeployedVersionNewer().then((outdated) => {
|
||||
if (outdated) onOutdated()
|
||||
})
|
||||
}
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
checkForUpdate()
|
||||
// Delay check on wake-up to allow the mobile network stack to stabilize
|
||||
setTimeout(checkForUpdate, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
const onOnline = () => {
|
||||
// Small delay to ensure connection is fully established
|
||||
setTimeout(checkForUpdate, 500)
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
const intervalId = window.setInterval(checkForUpdate, UPDATE_CHECK_INTERVAL_MS)
|
||||
window.addEventListener('online', onOnline)
|
||||
const updateIntervalId = window.setInterval(checkForUpdate, UPDATE_CHECK_INTERVAL_MS)
|
||||
|
||||
checkForUpdate()
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
window.clearInterval(intervalId)
|
||||
window.removeEventListener('online', onOnline)
|
||||
window.clearInterval(updateIntervalId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +74,18 @@ function reloadForServiceWorkerTakeover(): void {
|
||||
|
||||
export function usePwaUpdate() {
|
||||
const cleanupRef = useRef<(() => void) | null>(null)
|
||||
const reloadFallbackTimerRef = useRef<number | null>(null)
|
||||
const forceRecoveryTimerRef = useRef<number | null>(null)
|
||||
const setNeedRefreshRef = useRef<((value: boolean) => void) | null>(null)
|
||||
const pendingNeedRefreshRef = useRef<boolean | null>(null)
|
||||
|
||||
const applyNeedRefresh = (value: boolean) => {
|
||||
if (setNeedRefreshRef.current) {
|
||||
setNeedRefreshRef.current(value)
|
||||
return
|
||||
}
|
||||
pendingNeedRefreshRef.current = value
|
||||
}
|
||||
|
||||
const {
|
||||
needRefresh: [needRefresh, setNeedRefresh],
|
||||
@@ -58,32 +93,56 @@ export function usePwaUpdate() {
|
||||
} = useRegisterSW({
|
||||
immediate: !import.meta.env.DEV,
|
||||
onNeedReload() {
|
||||
reloadForServiceWorkerTakeover()
|
||||
if (isUpdateSuppressed()) return
|
||||
applyNeedRefresh(true)
|
||||
},
|
||||
onNeedRefresh() {
|
||||
if (isUpdateSuppressed()) return
|
||||
setNeedRefresh(true)
|
||||
applyNeedRefresh(true)
|
||||
},
|
||||
onRegisteredSW(_swUrl: string, registration: ServiceWorkerRegistration | undefined) {
|
||||
if (!registration) return
|
||||
|
||||
if (isUpdateSuppressed() || !registration.waiting) {
|
||||
setNeedRefresh(false)
|
||||
applyNeedRefresh(false)
|
||||
}
|
||||
|
||||
cleanupRef.current?.()
|
||||
cleanupRef.current = scheduleUpdateChecks(registration)
|
||||
cleanupRef.current = scheduleUpdateChecks(registration, () => {
|
||||
if (isUpdateSuppressed()) return
|
||||
applyNeedRefresh(true)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
setNeedRefreshRef.current = setNeedRefresh
|
||||
|
||||
useEffect(() => {
|
||||
if (isUpdateSuppressed()) {
|
||||
setNeedRefresh(false)
|
||||
} else if (pendingNeedRefreshRef.current !== null) {
|
||||
const pending = pendingNeedRefreshRef.current
|
||||
pendingNeedRefreshRef.current = null
|
||||
setNeedRefresh(pending)
|
||||
}
|
||||
|
||||
void isDeployedVersionNewer().then((outdated) => {
|
||||
if (outdated) {
|
||||
setNeedRefresh(true)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cleanupRef.current?.()
|
||||
cleanupRef.current = null
|
||||
if (reloadFallbackTimerRef.current !== null) {
|
||||
window.clearTimeout(reloadFallbackTimerRef.current)
|
||||
reloadFallbackTimerRef.current = null
|
||||
}
|
||||
if (forceRecoveryTimerRef.current !== null) {
|
||||
window.clearTimeout(forceRecoveryTimerRef.current)
|
||||
forceRecoveryTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [setNeedRefresh])
|
||||
|
||||
@@ -92,11 +151,24 @@ export function usePwaUpdate() {
|
||||
suppressUpdatePrompt()
|
||||
|
||||
await updateServiceWorker(true)
|
||||
await triggerServiceWorkerUpdate()
|
||||
|
||||
// vite-plugin-pwa reloads via the "controlling" event; fallback if that does not fire.
|
||||
window.setTimeout(() => {
|
||||
if (reloadFallbackTimerRef.current !== null) {
|
||||
window.clearTimeout(reloadFallbackTimerRef.current)
|
||||
}
|
||||
if (forceRecoveryTimerRef.current !== null) {
|
||||
window.clearTimeout(forceRecoveryTimerRef.current)
|
||||
}
|
||||
|
||||
reloadFallbackTimerRef.current = window.setTimeout(() => {
|
||||
reloadFallbackTimerRef.current = null
|
||||
reloadForServiceWorkerTakeover()
|
||||
}, UPDATE_RELOAD_FALLBACK_MS)
|
||||
|
||||
forceRecoveryTimerRef.current = window.setTimeout(() => {
|
||||
forceRecoveryTimerRef.current = null
|
||||
void forcePwaRecovery()
|
||||
}, UPDATE_HARD_RECOVERY_MS)
|
||||
}
|
||||
|
||||
const dismissUpdate = () => {
|
||||
|
||||
@@ -3,12 +3,19 @@ import { initReactI18next } from 'react-i18next'
|
||||
import LanguageDetector from 'i18next-browser-languagedetector'
|
||||
import enJson from './locales/en.json'
|
||||
import deJson from './locales/de.json'
|
||||
import daJson from './locales/da.json'
|
||||
import svJson from './locales/sv.json'
|
||||
import nbJson from './locales/nb.json'
|
||||
import { initSeo } from '../utils/seo.js'
|
||||
import { SUPPORTED_LANGUAGES } from '../utils/i18nLanguages.js'
|
||||
|
||||
/** JSON files wrap strings in `translation` — register that namespace explicitly. */
|
||||
const resources = {
|
||||
en: { translation: enJson.translation },
|
||||
de: { translation: deJson.translation }
|
||||
de: { translation: deJson.translation },
|
||||
da: { translation: daJson.translation },
|
||||
sv: { translation: svJson.translation },
|
||||
nb: { translation: nbJson.translation }
|
||||
}
|
||||
|
||||
i18n
|
||||
@@ -18,7 +25,7 @@ i18n
|
||||
resources,
|
||||
defaultNS: 'translation',
|
||||
fallbackLng: 'en',
|
||||
supportedLngs: ['de', 'en'],
|
||||
supportedLngs: [...SUPPORTED_LANGUAGES],
|
||||
nonExplicitSupportedLngs: true,
|
||||
load: 'languageOnly',
|
||||
interpolation: {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import deJson from '../i18n/locales/de.json'
|
||||
import enJson from '../i18n/locales/en.json'
|
||||
import daJson from '../i18n/locales/da.json'
|
||||
import svJson from '../i18n/locales/sv.json'
|
||||
import nbJson from '../i18n/locales/nb.json'
|
||||
|
||||
function collectKeys(obj: Record<string, unknown>, prefix = ''): string[] {
|
||||
const keys: string[] = []
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const path = prefix ? `${prefix}.${key}` : key
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
keys.push(...collectKeys(value as Record<string, unknown>, path))
|
||||
} else {
|
||||
keys.push(path)
|
||||
}
|
||||
}
|
||||
return keys.sort()
|
||||
}
|
||||
|
||||
const bundles = {
|
||||
de: deJson.translation,
|
||||
en: enJson.translation,
|
||||
da: daJson.translation,
|
||||
sv: svJson.translation,
|
||||
nb: nbJson.translation
|
||||
} as const
|
||||
|
||||
describe('i18n locale key parity', () => {
|
||||
const masterKeys = collectKeys(bundles.de)
|
||||
|
||||
it.each(Object.keys(bundles).filter((lang) => lang !== 'de'))(
|
||||
'%s has the same keys as de',
|
||||
(lang) => {
|
||||
const keys = collectKeys(bundles[lang as keyof typeof bundles])
|
||||
expect(keys).toEqual(masterKeys)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,990 @@
|
||||
{
|
||||
"translation": {
|
||||
"app": {
|
||||
"name": "Kapteins Daagbok",
|
||||
"tagline": "Privat yacht-logbog",
|
||||
"beta": "Beta",
|
||||
"beta_hint": "Betaversion - funktioner kan stadig ændres"
|
||||
},
|
||||
"languages": {
|
||||
"de": "Deutsch",
|
||||
"en": "English",
|
||||
"da": "Dansk",
|
||||
"sv": "Svenska",
|
||||
"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": {
|
||||
"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_stay": "Bliv her",
|
||||
"unsaved_changes_save_leave": "Gem og forlad",
|
||||
"unsaved_changes_discard": "Kassér",
|
||||
"unsaved_changes_leave": "Forladelse"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"vessel": "Skibsdata",
|
||||
"crew": "Crew",
|
||||
"deviation": "Tabel over distraktioner",
|
||||
"logs": "Indlæg i logbogen",
|
||||
"stats": "Statistik",
|
||||
"settings": "Indstillinger"
|
||||
},
|
||||
"auth": {
|
||||
"welcome": "Velkommen til Kapteins Daagbok.",
|
||||
"tagline": "Din sikre, E2E-krypterede maritime logbog.",
|
||||
"register": "Registrer dig med Passkey.",
|
||||
"login": "Log ind med Passkey.",
|
||||
"login_as": "Log ind som {{name}}",
|
||||
"quick_login": "Hurtigt login",
|
||||
"forget_account": "Glemt konto på denne enhed",
|
||||
"not_user": "Ikke {{name}}?",
|
||||
"recovery_title": "Din genoprettelsesnøgle",
|
||||
"recovery_warning": "VIGTIGT: Skriv disse 12 ord ned. Hvis du mister din Passkey og disse ord, kan dine data ikke gendannes.",
|
||||
"confirm_recovery": "Jeg har skrevet ordene ned",
|
||||
"status_logged_in": "Logget ind",
|
||||
"status_logged_out": "Aflyst",
|
||||
"copied": "Kopieret!",
|
||||
"copy_phrase": "Kopieringstast",
|
||||
"enter_recovery": "Indtast genoprettelsesnøgle",
|
||||
"recovery_fallback_warning": "Din Passkey er blevet godkendt, men din enhed understøtter ikke hardwarebaseret nøgleafledning. Indtast din genoprettelsesnøgle på 12 ord for at dekryptere din logbog.",
|
||||
"recovery_placeholder": "Indtast din genoprettelsesnøgle, som består af 12 ord adskilt af mellemrum...",
|
||||
"back": "Tilbage",
|
||||
"decrypting": "Dekryptering...",
|
||||
"decrypt_logbook": "Afkodning af logbog",
|
||||
"error_incorrect_recovery": "Forkert genoprettelsesnøgle. Dekryptering mislykkedes.",
|
||||
"error_decryption_failed": "Dekryptering mislykkedes. Tjek venligst din genoprettelsesnøgle.",
|
||||
"or_register": "eller registrer dig",
|
||||
"explore_demo": "Udforsk demoen uden en konto",
|
||||
"username_placeholder": "Brugernavn / skippernavn",
|
||||
"processing": "Behandling...",
|
||||
"help": "Hjælp",
|
||||
"setup_pin_title": "Opsæt lokal PIN-kode (valgfrit)",
|
||||
"setup_pin_warning": "Da din enhed ikke understøtter direkte Passkey-nøgleafledning, ville du ellers være nødt til at indtaste din 12-ordsnøgle, hver gang du logger ind på denne enhed. Opsæt en lokal PIN-kode for at undgå dette.",
|
||||
"pin_placeholder": "E.G. 123456",
|
||||
"pin_label": "Lokal PIN-kode (4-8 cifre)",
|
||||
"save_pin": "Gem PIN-kode og fortsæt",
|
||||
"skip_pin": "Spring over og brug gendannelse",
|
||||
"enter_pin_title": "Afkodning med PIN-kode",
|
||||
"enter_pin_warning": "Indtast din lokale PIN-kode for at låse op for dekrypteringsnøglen på denne enhed.",
|
||||
"enter_pin_placeholder": "Indtast din pinkode...",
|
||||
"decrypt_with_pin": "Afkodning",
|
||||
"use_recovery_instead": "Brug genoprettelsesnøgler i stedet",
|
||||
"error_incorrect_pin": "Forkert PIN-kode. Dekryptering mislykkedes.",
|
||||
"error_invalid_host": "Passkeys virker ikke via 127.0.0.1. Åbn appen via localhost.",
|
||||
"use_localhost_link": "Skift til localhost",
|
||||
"error_passkey_cancelled": "Passkey-login blev annulleret eller udløb. Prøv igen.",
|
||||
"error_invalid_rp_id": "Passkey-domæne matcher ikke (RP ID). Brug http://localhost:5173 med RP_ID=localhost i .env til lokal udvikling.",
|
||||
"error_session_incomplete": "Login ufuldstændig. Log ind med passkey igen."
|
||||
},
|
||||
"pwa": {
|
||||
"title": "Installer app",
|
||||
"generic_benefit": "Installer Kapteins Daagbok på din enhed for at få hurtigere adgang, offline-brug og permanent datalagring.",
|
||||
"ios_instructions": "På iPad/iPhone: Føj appen til startskærmen, så dine logbogsdata forbliver beskyttet, og appen starter som en indbygget app.",
|
||||
"ios_step_share": "Tryk på aktiesymbolet i Safari-linjen",
|
||||
"ios_step_add": "Vælg \"Gå til startskærm\"",
|
||||
"install_now": "Installer nu",
|
||||
"installing": "Installation...",
|
||||
"later": "Senere",
|
||||
"never": "Vis ikke mere",
|
||||
"platform_ios": "Installation via Safari.",
|
||||
"platform_android": "Installation via browseren",
|
||||
"platform_desktop": "Installation som desktop-app",
|
||||
"settings_section": "Installation af app",
|
||||
"update_title": "Opdatering tilgængelig",
|
||||
"update_desc": "En ny version af Kapteins Daagbok er klar. Opdater venligst for at få de seneste ændringer.",
|
||||
"update_now": "Opdater nu",
|
||||
"update_reloading": "Indlæser...",
|
||||
"storage_persist_hint": "Browseren kan slette offline-data. Tillad permanent lagring, så din logbog forbliver beskyttet."
|
||||
},
|
||||
"sync": {
|
||||
"status_synced": "Synkroniseret",
|
||||
"status_syncing": "Synkroniser...",
|
||||
"status_offline": "Offline-cache",
|
||||
"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": {
|
||||
"title": "Skibets stamdata",
|
||||
"name": "Yacht-navn",
|
||||
"type": "Yacht-type",
|
||||
"type_unset": "- ikke specificeret -",
|
||||
"type_sailing": "Sejlbåd",
|
||||
"type_motor": "Motorbåd",
|
||||
"length_m": "Længde (m)",
|
||||
"draft_m": "Dybgang (m)",
|
||||
"air_draft_m": "Højde (m)",
|
||||
"invalid_metric": "Ugyldig numerisk værdi - indtast venligst meter som et decimaltal (f.eks. 12,5).",
|
||||
"port": "Hjemmehavn",
|
||||
"owner": "Ejer",
|
||||
"charter": "Charterselskab",
|
||||
"registration": "Nummerplade/registreringsnummer",
|
||||
"callsign": "Radiokaldesignal",
|
||||
"atis": "ATIS nr.",
|
||||
"mmsi": "MMSI-nr.",
|
||||
"save": "Gem skibsdata",
|
||||
"saving": "Vil blive reddet...",
|
||||
"saved": "Skibsdata er gemt med succes!",
|
||||
"loading": "Skibsdata er indlæst...",
|
||||
"sails_list": "Sejl (eksisterende sejl)",
|
||||
"sails_help": "Indtast de sejl, der er tilgængelige på din båd her (f.eks. storsejl, genua, fok).",
|
||||
"add_sail": "Tilføj sejl",
|
||||
"sail_name_placeholder": "z. f.eks. storsejl",
|
||||
"no_sails": "Ingen sejl opbevaret.",
|
||||
"photo_add": "Tilføj foto",
|
||||
"photo_change": "Skift foto",
|
||||
"photo_delete": "Slet foto",
|
||||
"tanks_section": "Tanke (kapacitet)",
|
||||
"tanks_help": "Valgfrit i liter - muliggør slider i journalen for kendte tankstørrelser.",
|
||||
"freshwater_capacity_l": "Drikkevand (liter)",
|
||||
"fuel_capacity_l": "Brændstof (liter)",
|
||||
"greywater_capacity_l": "Gråt vand (liter)",
|
||||
"invalid_tank_liters": "Ugyldig numerisk værdi - indtast venligst liter som et tal (f.eks. 200)."
|
||||
},
|
||||
"logs": {
|
||||
"title": "Logbogsdagbog",
|
||||
"new_entry": "Ny rejsedag",
|
||||
"travel_details": "Detaljer om rejsen",
|
||||
"add_event": "Tilføj ny logbogspost",
|
||||
"add_event_btn": "Tilføj begivenhed",
|
||||
"edit_event": "Rediger begivenhed",
|
||||
"save_event_btn": "Gem ændring",
|
||||
"cancel_event_edit": "Annuller",
|
||||
"delete_event": "Slet begivenhed",
|
||||
"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.",
|
||||
"date": "dato",
|
||||
"day_of_travel": "Rejsedag",
|
||||
"travel_day_number": "Rejsedag {{number}}",
|
||||
"departure": "Starthavn (rejse fra)",
|
||||
"destination": "Destinationsport (til)",
|
||||
"route": "Rejse fra/til",
|
||||
"freshwater": "Ferskvand (liter)",
|
||||
"fuel": "Treibstoff / Brændstof (liter)",
|
||||
"greywater": "Gråt vand (liter)",
|
||||
"greywater_level": "Fyldningsniveau",
|
||||
"tank_slider_of_max": "{{current}} / {{max}} L",
|
||||
"tank_capacity_tooltip": "Hvis tankkapaciteten (liter) er gemt i skibsdataene, kan du indtaste fyldningsniveauerne her ved hjælp af skyderen.",
|
||||
"morning": "Stå op om morgenen",
|
||||
"refilled": "Genopfyldt",
|
||||
"evening": "Stand om aftenen",
|
||||
"consumption": "Dagligt forbrug",
|
||||
"signatures": "Underskrifter / frigivelse",
|
||||
"sign_skipper": "Skippers underskrift",
|
||||
"sign_crew": "Crew-signatur",
|
||||
"sign_hint": "Tegn med finger, pen eller mus",
|
||||
"sign_clear": "Sletning",
|
||||
"sign_export_image": "[Underskrift]",
|
||||
"sign_with_passkey": "Frigør med Passkey.",
|
||||
"sign_passkey_signing": "Der anmodes om Passkey...",
|
||||
"sign_passkey_signed": "Udgivet af {{username}}",
|
||||
"sign_passkey_export": "Passkey: {{username}} ({{date}})",
|
||||
"sign_attribution_export": "{{username}} ({{date}})",
|
||||
"sign_passkey_clear": "Fjern Passkey-frigivelse",
|
||||
"sign_mode_passkey": "Passkey",
|
||||
"sign_mode_classic": "Klassisk",
|
||||
"sign_passkey_failed": "Passkey Frigivelse mislykkedes",
|
||||
"sign_passkey_cancelled": "Passkey Frigivelse annulleret",
|
||||
"sign_invalid": "Signatur ugyldig - indholdet er blevet ændret",
|
||||
"sign_badge_skipper": "Skipper",
|
||||
"sign_badge_skipper_invalid": "Ugyldig",
|
||||
"sign_badge_skipper_title_valid": "Skipper har udgivet",
|
||||
"sign_badge_skipper_title_invalid": "Skippers signatur er ugyldig - indholdet er blevet ændret",
|
||||
"sign_classic_or_passkey": "Valgfrit: klassisk underskrift eller Passkey-frigivelse ovenfor",
|
||||
"sign_crew_passkey_hint": "Crew-medlemmer med skriveadgang kan frigive via Passkey.",
|
||||
"sign_offline_hint": "Passkey-Godkendelse kræver internet - klassisk underskrift mulig offline",
|
||||
"sign_lock_notice": "Efter underskrivelsen kan der ikke foretages ændringer i logbogen (undtagen fotos), uden at skipper og crew skal skrive under igen.",
|
||||
"sign_lock_active": "Denne post er underskrevet. Ændringer i logbogen (undtagen fotos) fjerner automatisk skipperens og crews underskrifter.",
|
||||
"sign_lock_warning_title": "Bekræft underskrift",
|
||||
"sign_lock_warning": "Efter underskrivelsen er det ikke længere muligt at foretage ændringer i logbogen (undtagen fotos), uden at skipper og crew skal skrive under igen.\n\nVil du gerne fortsætte?",
|
||||
"sign_proceed": "Tegn",
|
||||
"sign_cancel": "Annuller",
|
||||
"sign_cleared_re_sign_title": "Underskrifter fjernet",
|
||||
"sign_cleared_re_sign": "Logbogsoptegnelsen er blevet ændret. Skipperens og crews underskrifter er blevet fjernet. Underskriv venligst igen.",
|
||||
"no_entries": "Ingen logbogsposter fundet for denne yacht. Opret din første rejsedag!",
|
||||
"back_to_list": "Tilbage til tidsskriftslisten",
|
||||
"save": "Gem logbogsside",
|
||||
"saving": "Vil blive reddet...",
|
||||
"saved": "Logbogsside gemt med succes!",
|
||||
"loading": "Dagbogen er ved at blive indlæst.",
|
||||
"view_mode_label": "Visning",
|
||||
"view_list": "Liste",
|
||||
"live_mode": "Live",
|
||||
"live_title": "Live-journal",
|
||||
"live_loading": "Live-journal indlæses...",
|
||||
"live_retry": "Prøv igen",
|
||||
"live_load_error": "Live-journal kunne ikke indlæses.",
|
||||
"live_action_error": "Indtastning kunne ikke gemmes.",
|
||||
"live_open_editor": "Fuld editor",
|
||||
"live_actions_label": "Hurtighandlinger",
|
||||
"live_stream_label": "Hændelseslog",
|
||||
"live_stream_title": "Journal",
|
||||
"live_no_events": "Ingen indtastninger endnu — tryk på en handling.",
|
||||
"live_motor_start": "Motor Start",
|
||||
"live_motor_stop": "Motor Stop",
|
||||
"live_cast_off": "Afsejling",
|
||||
"live_moor": "Anløb",
|
||||
"live_sails_btn": "Sejl",
|
||||
"live_sails_pick": "Vælg sejl",
|
||||
"live_sails_pick_hint": "Tryk på flere sejl (tryk igen for at fravælge), og indtast derefter.",
|
||||
"live_sails_selected": "Valgt: {{sails}}",
|
||||
"live_sails_confirm": "Indtast",
|
||||
"live_sails_confirm_count": "Indtast ({{count}})",
|
||||
"live_sails": "Sejl: {{sails}}",
|
||||
"live_fix": "Fix",
|
||||
"live_fix_coords": "Fix {{lat}}, {{lng}}",
|
||||
"live_fix_manual_hint": "GPS ikke tilgængelig. Indtast bredde- og længdegrad manuelt, eller prøv igen med GPS-knappen.",
|
||||
"live_fix_gps_loading": "Henter GPS-position…",
|
||||
"live_fix_invalid": "Indtast gyldige koordinater (bredde −90…90, længde −180…180).",
|
||||
"live_fix_lat_placeholder": "Bredde (Lat)",
|
||||
"live_fix_lng_placeholder": "Længde (Lng)",
|
||||
"live_photo_btn": "Foto (kamera)",
|
||||
"live_photo_capture_btn": "Tag billede",
|
||||
"live_photo_save_btn": "Gem",
|
||||
"live_photo_retake_btn": "Tag igen",
|
||||
"live_photo_capture_failed": "Optagelse mislykkedes. Prøv igen.",
|
||||
"live_photo_open_camera_btn": "Åbn kamera",
|
||||
"live_photo_native_hint": "Tag et foto med enhedens kamera og gem det her bagefter.",
|
||||
"live_photo_camera_starting": "Starter kamera…",
|
||||
"live_photo_camera_denied": "Kameraadgang nægtet eller utilgængelig.",
|
||||
"live_photo_camera_unavailable": "Kamera understøttes ikke i denne browser.",
|
||||
"live_photo_error": "Foto kunne ikke gemmes.",
|
||||
"live_photo_entry": "Foto: {{caption}}",
|
||||
"live_photo_entry_plain": "Foto taget",
|
||||
"live_undo_photo_hint": "Foto gemt",
|
||||
"live_comment_btn": "Kommentar",
|
||||
"live_comment_placeholder": "Indtast tekst…",
|
||||
"live_comment_confirm": "Indtast",
|
||||
"live_gps_error": "GPS-position kunne ikke bestemmes.",
|
||||
"live_gps_start_hint": "Begynd altid dagens rejse med en position.",
|
||||
"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_visibility_btn": "Sigtbarhed",
|
||||
"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_visibility_entry": "Sigtbarhed {{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_visibility_placeholder": "f.eks. 10 km",
|
||||
"live_course_placeholder": "f.eks. 245",
|
||||
"live_fuel_placeholder": "Optankede liter",
|
||||
"live_water_placeholder": "Optankede liter",
|
||||
"live_sog_btn": "SOG",
|
||||
"live_stw_btn": "STW",
|
||||
"live_sog_entry": "SOG {{speed}} kn",
|
||||
"live_stw_entry": "STW {{speed}} kn",
|
||||
"live_sog_placeholder": "f.eks. 5,2",
|
||||
"live_stw_placeholder": "f.eks. 4,8",
|
||||
"live_sog_hint": "Fart over grund (kn) — GPS-værdi forudfyldes, hvis tilgængelig.",
|
||||
"delete_entry": "Slet tag",
|
||||
"delete_confirm": "Er du sikker på, at du vil slette denne rejsedag permanent?",
|
||||
"carry_over_tanks_title": "Overføre data fra den foregående dag?",
|
||||
"carry_over_tanks_confirm": "Overtage starthavn, ferskvand, brændstof og gråvand fra den sidste dag på turen?\n\nStarthavn: {{departure}}\nFerskvand: {{fw}} L\nBrændstof: {{fuel}} L\nGråt vand: {{greywater}} L",
|
||||
"carry_over_tanks_yes": "Tag over",
|
||||
"carry_over_tanks_no": "Start med 0",
|
||||
"event_title": "Kronologisk hændelseslog",
|
||||
"no_events": "Der er endnu ikke indtastet nogen begivenheder for denne rejsedag.",
|
||||
"event_time": "Tidspunkt på dagen",
|
||||
"event_mgk": "MgK-kursus",
|
||||
"event_rwk": "RwK-kursus",
|
||||
"event_course_section": "Kursus",
|
||||
"course_dial_hint": "Drej ringen eller indtast grader",
|
||||
"course_dial_step_label": "Trinstørrelse",
|
||||
"course_step_fine": "1°",
|
||||
"course_step_medium": "5°",
|
||||
"course_step_coarse": "10°",
|
||||
"course_tab_mgk": "MgK",
|
||||
"course_tab_rwk": "rwK",
|
||||
"course_invalid": "Ugyldigt kursus (0-360)",
|
||||
"course_placeholder_degrees": "z. B. 180",
|
||||
"course_placeholder_cardinal": "z. E.G. NW",
|
||||
"compass_n": "N",
|
||||
"compass_e": "O",
|
||||
"compass_s": "S",
|
||||
"compass_w": "W",
|
||||
"wind_mode_cardinal": "Kardinal",
|
||||
"wind_mode_degrees": "Som grad",
|
||||
"event_wind_direction": "Vindretning",
|
||||
"event_wind_strength": "Vindstyrke",
|
||||
"event_sea_state": "Havets tilstand",
|
||||
"event_visibility": "Sigtbarhed",
|
||||
"event_visibility_placeholder": "f.eks. 10 km",
|
||||
"weather_slider_unset": "—",
|
||||
"weather_slider_pressure": "{{value}} hPa",
|
||||
"weather_slider_sea_state": "Trin {{value}}",
|
||||
"weather_slider_heel": "{{value}}°",
|
||||
"event_weather": "Vejret",
|
||||
"event_log": "Log (sm)",
|
||||
"event_gps": "GPS-position",
|
||||
"event_location": "Sted/havn",
|
||||
"event_location_placeholder": "z. f.eks. Kiel",
|
||||
"event_remarks": "Bemærkninger / hændelser",
|
||||
"gps_btn": "Hent GPS-koordinater",
|
||||
"weather_btn": "OpenWeatherMap Kald vejret op",
|
||||
"event_wind_pressure": "Lufttryk (hPa)",
|
||||
"event_heel": "Krængning (°)",
|
||||
"event_sails": "Sejlhåndtering/motor",
|
||||
"motor_propulsion": "Kørsel med maskine",
|
||||
"sails_picker_show_more": "Vis alle sejl",
|
||||
"sails_picker_show_less": "Vis mindre",
|
||||
"motor_hours": "Maskintimer (i alt)",
|
||||
"fuel_per_motor_hour": "Forbrug pr. maskintime",
|
||||
"event_distance": "Afstand (nm)",
|
||||
"export_csv": "Download CSV.",
|
||||
"share_csv": "CSV andel",
|
||||
"export_pdf": "Download PDF.",
|
||||
"exporting_pdf": "PDF er genereret...",
|
||||
"photos_title": "Vedhæftede billeder (E2E-krypteret)",
|
||||
"photo_caption_label": "Fotobeskrivelse/etiket (valgfrit)",
|
||||
"photo_caption_placeholder": "f.eks. at sætte sejl nær indsejlingen til havnen",
|
||||
"photo_btn": "Tag foto / upload",
|
||||
"photo_processing": "Er ved at blive behandlet...",
|
||||
"no_photos": "Der er endnu ingen billeder knyttet til denne rejsedag.",
|
||||
"photo_delete_confirm": "Er du sikker på, at du vil slette dette foto permanent?",
|
||||
"confirm_yes": "Ja",
|
||||
"confirm_no": "Nej",
|
||||
"track_upload_title": "GPS-spor (fil)",
|
||||
"track_upload_points": "Point",
|
||||
"gps_tracking_btn_gpx": "Download sporfilen",
|
||||
"gps_track_upload_help": "Træk en GPX-, KML- eller GeoJSON-fil hertil, eller klik for at vælge",
|
||||
"gps_track_upload_btn": "Upload GPS-spor",
|
||||
"gps_track_delete": "Slet sporfilen",
|
||||
"gps_track_delete_confirm": "Er du sikker på, at du vil slette denne sporfil permanent?",
|
||||
"track_distance": "GPS-rute (sm)",
|
||||
"track_speed_max": "Maks. Hastighed (kn)",
|
||||
"track_speed_avg": "Ø Hastighed (kn)",
|
||||
"track_map_title": "GPS-spor på OpenSeaMap",
|
||||
"track_map_start": "Start",
|
||||
"track_map_end": "Mål",
|
||||
"track_map_speed_slow": "langsomt",
|
||||
"track_map_speed_fast": "hurtigt",
|
||||
"track_map_error": "Kortet kunne ikke indlæses.",
|
||||
"exporting": "Eksport...",
|
||||
"share_unsupported": "Deling understøttes ikke på denne enhed. Filen er blevet downloadet i stedet.",
|
||||
"invite_crew": "Inviter crewen",
|
||||
"invite_link_copied": "Invitationslink kopieret til udklipsholderen!",
|
||||
"invite_link_desc": "Del dette link med Crew-medlemmer for at give dem skriveadgang til denne logbog.",
|
||||
"collaborators_list": "Medlemmer / crew",
|
||||
"revoke": "Fjerne",
|
||||
"revoke_confirm": "Er du sikker på, at du vil tilbagekalde dette Crew-medlems adgang?",
|
||||
"invite_role": "Rolle",
|
||||
"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": {
|
||||
"title": "Dine logbøger",
|
||||
"subtitle": "Vælg en logbog, eller opret en ny til at styre dine rejser.",
|
||||
"create_btn": "Opret logbog",
|
||||
"new_logbook_placeholder": "Navn på logbog eller yacht",
|
||||
"logout": "Log ud",
|
||||
"logged_in_as": "Logget ind som {{name}}",
|
||||
"delete_confirm": "Er du sikker på, at du vil slette denne logbog permanent? Alle lokale data og serverkopier vil blive destrueret.\n\nTip: Lav en sikkerhedskopi (.daagbok.json) på forhånd under Indstillinger → Sikkerhedskopiering og gendannelse, hvis du vil beholde dataene senere.",
|
||||
"no_logbooks": "Ingen logbøger fundet. Opret din første logbog for at komme i gang!",
|
||||
"loading": "Logbøgerne er fyldt op...",
|
||||
"status_synced": "Synkroniseret",
|
||||
"status_local": "Kun lokal cache",
|
||||
"delete_btn": "Slet logbog",
|
||||
"section_owned": "Mine logbøger",
|
||||
"section_shared": "Fælles logbøger",
|
||||
"section_shared_hint": "Du er blevet inviteret som Crew-medlem. Skipperprofil og indstillinger tilhører ejeren.",
|
||||
"role_owner": "Egen logbog",
|
||||
"role_owner_hint": "Du er ejer og skipper af denne logbog",
|
||||
"role_crew": "Adgang for crew",
|
||||
"role_crew_hint": "Inviteret logbog - du kan arbejde som crew og underskrive den",
|
||||
"role_read": "Læs kun",
|
||||
"role_read_hint": "Opdelt logbog - kun visning, ingen redigering",
|
||||
"open_profile": "Åben profil af {{name}}",
|
||||
"open_logbook": "Åbn logbog „{{title}}“",
|
||||
"edit_title": "Omdøb logbog",
|
||||
"edit_placeholder": "Nyt navn på logbogen",
|
||||
"edit_success": "Logbog omdøbt med succes",
|
||||
"edit_btn": "Omdøb",
|
||||
"filter_label": "Filtrer logbøger",
|
||||
"filter_placeholder": "Navn, årstal, dato, crew eller skib …",
|
||||
"filter_clear": "Nulstil filter",
|
||||
"filter_results": "{{count}} Hits",
|
||||
"filter_no_results": "Ingen logbøger matcher din søgning. Prøv med et andet navn eller et andet år.",
|
||||
"sort_label": "Sortere",
|
||||
"sort_by_label": "Sorter efter",
|
||||
"sort_by_name": "Navn",
|
||||
"sort_by_date": "dato",
|
||||
"sort_dir_label": "Sekvens",
|
||||
"sort_asc": "Stigende",
|
||||
"sort_desc": "Nedadgående",
|
||||
"sort_name_asc": "Navn A til Z",
|
||||
"sort_name_desc": "Navn Z til A",
|
||||
"sort_date_asc": "Ældste først",
|
||||
"sort_date_desc": "Nyeste først"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Brugerprofil",
|
||||
"subtitle": "Konto, Passkeys og statistik for {{name}}.",
|
||||
"back": "Tilbage til instrumentbrættet",
|
||||
"loading": "Profilen er ved at blive indlæst...",
|
||||
"load_error": "Profilen kunne ikke indlæses.",
|
||||
"copy_failed": "Kopiering mislykkedes.",
|
||||
"processing": "Er ved at blive behandlet...",
|
||||
"identity_title": "Konto-identitet",
|
||||
"username": "Brugernavn",
|
||||
"user_id": "Bruger-ID",
|
||||
"copy_user_id": "Kopier bruger-ID",
|
||||
"account_since": "Konto siden",
|
||||
"prf_status": "Passkey nøgleafledning (PRF)",
|
||||
"prf_active": "Aktiv",
|
||||
"prf_inactive": "Ikke sat op",
|
||||
"passkeys_title": "Passkeys",
|
||||
"passkeys_desc": "Registrer en separat Passkey på hver enhed. På den måde kan du logge ind, selv når du har skiftet platform.",
|
||||
"passkeys_empty": "Ingen Passkeys fundet.",
|
||||
"add_passkey_btn": "Tilføj ny Passkey.",
|
||||
"add_passkey_success": "Passkey tilføjet med succes.",
|
||||
"add_passkey_failed": "Passkey kunne ikke tilføjes.",
|
||||
"remove_passkey_btn": "Fjern Passkey",
|
||||
"remove_passkey_last_title": "Sidste Passkey.",
|
||||
"remove_passkey_last_desc": "Den eneste Passkey kan ikke fjernes uden at miste adgangen til din konto. Hvis du vil slette kontoen helt, skal du bruge farezonen nederst på denne side.",
|
||||
"remove_passkey_failed": "Passkey kunne ikke fjernes.",
|
||||
"remove_passkey_confirm_title": "Fjern Passkey?",
|
||||
"remove_passkey_confirm_desc": "Denne enhed kan så ikke længere logge ind med denne Passkey.",
|
||||
"remove_passkey_confirm_yes": "Fjerne",
|
||||
"remove_passkey_confirm_no": "Annuller",
|
||||
"pin_title": "Lokal PIN-kode",
|
||||
"pin_status": "Status",
|
||||
"pin_active": "Aktiv på denne enhed",
|
||||
"pin_inactive": "Ikke sat op",
|
||||
"pin_confirm_label": "Bekræft PIN-kode",
|
||||
"pin_confirm_placeholder": "Indtast PIN-kode igen",
|
||||
"pin_set_btn": "Opsæt PIN-kode",
|
||||
"pin_change_btn": "Skift PIN-kode",
|
||||
"pin_remove_btn": "Fjern PIN-kode",
|
||||
"pin_saved": "PIN-kode gemt.",
|
||||
"pin_save_failed": "PIN-koden kunne ikke gemmes.",
|
||||
"pin_mismatch": "PIN-koderne stemmer ikke overens.",
|
||||
"pin_length_error": "PIN-koden skal bestå af mindst 4 tegn.",
|
||||
"pin_no_session": "Sessionen er udløbet - tilmeld dig venligst igen.",
|
||||
"remove_pin_confirm_title": "Fjerne PIN-kode?",
|
||||
"remove_pin_confirm_desc": "Du skal logge ind igen på denne enhed med Passkey eller genoprettelsesnøglen.",
|
||||
"remove_pin_confirm_yes": "Fjern PIN-kode",
|
||||
"remove_pin_confirm_no": "Annuller",
|
||||
"security_title": "Tjekliste for sikkerhed",
|
||||
"security_desc": "Oversigt over de vigtigste beskyttelsesmekanismer for din konto.",
|
||||
"security_passkeys_ok": "Mindst én Passkey registreret",
|
||||
"security_passkeys_missing": "Nej Passkey registreret",
|
||||
"security_prf_ok": "PRF-nøgleafledning aktiv",
|
||||
"security_prf_missing": "PRF ikke sat op",
|
||||
"security_pin_ok": "Lokal PIN-kode på denne enhed",
|
||||
"security_pin_missing": "Ingen lokal PIN-kode",
|
||||
"security_recovery_ok": "Opsætning af genoprettelsesnøgle",
|
||||
"security_recovery_hint": "De 12 ord blev vist under registreringen. Opbevar dem offline og adskilt fra enheden. Du kan oprette en ny nøgle nedenfor - den gamle bliver så ugyldig.",
|
||||
"recovery_rotate_btn": "Opret en ny genoprettelsesnøgle",
|
||||
"recovery_rotate_confirm_title": "Opret en ny genoprettelsesnøgle?",
|
||||
"recovery_rotate_confirm_desc": "Den tidligere nøgle på 12 ord bliver ugyldig med det samme. Sørg for at opbevare den nye nøgle sikkert, før du fortsætter.",
|
||||
"recovery_rotate_confirm_yes": "Opret ny nøgle",
|
||||
"recovery_rotate_confirm_no": "Annuller",
|
||||
"recovery_rotate_new_warning": "VIGTIGT: Skriv disse 12 ord ned, og opbevar dem offline. Den tidligere genoprettelsesnøgle er nu ugyldig.",
|
||||
"recovery_rotate_failed": "Genoprettelsesnøglen kunne ikke oprettes.",
|
||||
"recovery_rotate_no_session": "Krypteringssessionen er udløbet - log ud og log ind igen, og prøv så igen.",
|
||||
"device_title": "Denne enhed",
|
||||
"device_desc": "Lokal cache, synkroniseringsstatus og hurtig login i denne browser.",
|
||||
"device_sync_pending": "{{count}} ventende synkroniseringsposter",
|
||||
"device_sync_ok": "Alle lokale ændringer synkroniseres",
|
||||
"device_remembered": "Konto til hurtigt login gemt på denne enhed",
|
||||
"device_not_remembered": "Kontoen er ikke på listen over hurtige login",
|
||||
"device_forget_btn": "Glemt konto på denne enhed",
|
||||
"device_forget_confirm_title": "Fjerne hurtig login?",
|
||||
"device_forget_confirm_desc": "Kontoen forsvinder fra listen over hurtige login på denne enhed. Din session og dine lokale logbøger bevares.",
|
||||
"device_forget_confirm_yes": "Fjerne",
|
||||
"device_forget_confirm_no": "Annuller",
|
||||
"passkey_label": "Navn på ny Passkey (valgfrit)",
|
||||
"passkey_label_placeholder": "z. f.eks. MacBook, iPhone.",
|
||||
"passkey_rename_btn": "Gem navn",
|
||||
"passkey_rename_success": "Passkey navn gemt.",
|
||||
"passkey_rename_failed": "Passkey-Navnet kunne ikke gemmes.",
|
||||
"passkey_unnamed": "Uden titel Passkey.",
|
||||
"stats_title": "Statistik",
|
||||
"stats_subtitle": "Om alle dine logbøger på denne enhed",
|
||||
"stats_logbooks": "Logbøger",
|
||||
"stats_account_since": "Konto siden",
|
||||
"stats_shared_logbooks": "Fælles logbøger",
|
||||
"appearance_title": "App og visualisering",
|
||||
"appearance_desc": "Designet og farveskemaet gælder for hele appen på denne enhed.",
|
||||
"theme_label": "Appens designstil",
|
||||
"theme_auto": "Automatisk (OS-registrering)",
|
||||
"theme_ocean": "Ocean (glasmorfisme)",
|
||||
"theme_material": "Materiale (Android)",
|
||||
"theme_cupertino": "Cupertino (iOS)",
|
||||
"color_scheme_label": "Lys eller mørk tilstand",
|
||||
"color_scheme_auto": "Automatisk (system)",
|
||||
"color_scheme_light": "Lys",
|
||||
"color_scheme_dark": "Mørk",
|
||||
"integrations_title": "Integrationer",
|
||||
"owm_key": "OpenWeatherMap API-nøgle",
|
||||
"owm_help": "Valgfrit: egen OpenWeatherMap API-nøgle. Hvis der ikke er nogen indtastning, bruges nøglen på serversiden fra operatørkonfigurationen.",
|
||||
"prefs_save": "Gemme",
|
||||
"prefs_saving": "Vil blive reddet...",
|
||||
"prefs_saved": "Gemt",
|
||||
"tour_title": "App-tur",
|
||||
"tour_desc": "Lad dig guide gennem de vigtigste områder i appen igen.",
|
||||
"tour_restart": "Start turen igen",
|
||||
"push_title": "Push-meddelelser",
|
||||
"push_desc": "Som logbogsejer får du besked, når inviterede Crew-medlemmer synkroniserer ændringer. Intet indhold overføres i ren tekst.",
|
||||
"push_enable": "Giv os besked om ændringer i crewen",
|
||||
"push_active": "Push-meddelelser er aktive på denne enhed.",
|
||||
"push_unsupported": "Push-meddelelser understøttes ikke i denne browser.",
|
||||
"push_denied_hint": "Notifikationer er blokeret. Tillad dem i browserens eller enhedens indstillinger.",
|
||||
"push_ios_install_hint": "På iPhone/iPad: Føj app til startskærmen (iOS 16.4+) for at bruge push.",
|
||||
"push_error": "Push-meddelelser kunne ikke aktiveres.",
|
||||
"sections": {
|
||||
"account": "Konto og indstillinger",
|
||||
"fleet": "Flåde og besætning",
|
||||
"security": "Sikkerhed og enhed",
|
||||
"stats": "Statistik",
|
||||
"danger": "Farezone"
|
||||
}
|
||||
},
|
||||
"vessel_pool": {
|
||||
"title": "Skibsflåde",
|
||||
"section_title": "Dine skibe",
|
||||
"subtitle": "Hold alle skibe til dine logbøger her. Vælg aktivt skib per logbog fra listen.",
|
||||
"loading": "Indlæser skibsflåde…",
|
||||
"add_vessel": "Tilføj skib",
|
||||
"edit_vessel": "Rediger skib",
|
||||
"no_vessels": "Ingen skibe i puljen endnu.",
|
||||
"delete_confirm": "Fjerne dette skib fra flåden?",
|
||||
"max_vessels": "Højst 20 skibe i puljen."
|
||||
},
|
||||
"logbook_vessel": {
|
||||
"title": "Skib for denne logbog",
|
||||
"subtitle": "Vælg skib for denne logbog. Rejsedage bruger sejl- og tankdata fra valgt skib.",
|
||||
"active_vessel": "Skib for denne logbog",
|
||||
"no_vessels_in_pool": "Intet skib i flåden – tilføj i brugerprofilen først.",
|
||||
"no_vessel": "Intet skib valgt",
|
||||
"unnamed": "Uden navn",
|
||||
"save": "Gem skib",
|
||||
"saved": "Logbog-skib gemt.",
|
||||
"selection_only_hint": "Du ser skibet ejeren har valgt (delt logbog).",
|
||||
"manage_in_profile": "Administrer skibe i brugerprofilen"
|
||||
},
|
||||
"person_pool": {
|
||||
"title": "Stamm-Crew og skippere",
|
||||
"subtitle": "Administrer din personpulje her – skippere og crew til alle logbøger. Vælg aktiv crew per logbog og rejsedag fra puljen.",
|
||||
"loading": "Indlæser personpulje…",
|
||||
"skippers_section": "Skippere",
|
||||
"crew_section": "Stamm-Crew",
|
||||
"add_skipper": "Tilføj skipper",
|
||||
"add_crew": "Tilføj Crew-medlem",
|
||||
"edit_skipper": "Rediger skipper",
|
||||
"no_skippers": "Ingen skipper i puljen endnu.",
|
||||
"no_crew": "Ingen Crew-medlemmer i puljen endnu.",
|
||||
"delete_confirm": "Fjern denne person fra puljen?"
|
||||
},
|
||||
"logbook_crew": {
|
||||
"title": "Crew for denne logbog",
|
||||
"subtitle": "Vælg skipper og crew for denne logbog. Nye rejsedage arver valget som standard.",
|
||||
"loading": "Indlæser crew…",
|
||||
"active_skipper": "Skipper for denne logbog",
|
||||
"active_crew": "Crew for denne logbog",
|
||||
"no_skippers_in_pool": "Ingen skipper i puljen – tilføj i brugerprofilen først.",
|
||||
"no_crew_in_pool": "Ingen crew i puljen – tilføj i brugerprofilen først.",
|
||||
"no_skipper": "Ingen skipper valgt",
|
||||
"unnamed": "Uden navn",
|
||||
"save": "Gem crew",
|
||||
"saved": "Logbog-Crew gemt.",
|
||||
"selection_only_hint": "Du ser den crew ejeren har valgt (delt logbog)."
|
||||
},
|
||||
"entry_crew": {
|
||||
"title": "Crew på denne rejsedag",
|
||||
"subtitle": "Kan afvige fra logbogstandard. Følgende dage arver fra foregående dag.",
|
||||
"day_skipper": "Skipper denne dag",
|
||||
"day_crew": "Crew denne dag",
|
||||
"no_skipper": "Ingen skipper valgt",
|
||||
"no_crew": "Ingen crew valgt"
|
||||
},
|
||||
"crew": {
|
||||
"title": "Skipper- og Crew-profiler",
|
||||
"skipper_section": "Skipper-profil",
|
||||
"skipper_read_only_hint": "Skipperprofilen kan kun redigeres af logbogens ejer.",
|
||||
"crew_section": "Crew-liste",
|
||||
"add_crew": "Tilføj Crew-medlem",
|
||||
"edit_crew": "Rediger Crew-medlem",
|
||||
"no_crew": "Ingen Crew-medlemmer tilføjet endnu.",
|
||||
"max_crew": "Det maksimale antal på 12 Crew-medlemmer i puljen er nået.",
|
||||
"name": "Navn",
|
||||
"address": "adresse",
|
||||
"birthdate": "Fødselsdag",
|
||||
"phone": "Telefonnummer",
|
||||
"nationality": "Nationalitet",
|
||||
"passport": "Pas/ID-nummer",
|
||||
"bloodtype": "Blodgruppe",
|
||||
"allergies": "Allergier",
|
||||
"diseases": "Eksisterende tilstande/sygdomme",
|
||||
"save": "Gem skipper-data",
|
||||
"save_member": "Gem medlem",
|
||||
"saved": "Skipperprofilen er blevet gemt!",
|
||||
"loading": "Crew-filerne er indlæst.",
|
||||
"delete_confirm": "Er du sikker på, at du vil fjerne dette Crew-medlem?"
|
||||
},
|
||||
"deviation": {
|
||||
"title": "Tabel over kompasafvigelser",
|
||||
"subtitle": "Indtast den magnetiske kompasafbøjning (afbøjning) for kurser (MgK) fra 000° til 360° i trin på 10°.",
|
||||
"heading": "MgK",
|
||||
"deviation": "Distraktion",
|
||||
"save": "Gem kalibreringsgitter",
|
||||
"saving": "Vil blive reddet...",
|
||||
"saved": "Kalibreringsgitteret er gemt med succes!",
|
||||
"loading": "Kalibreringstabellen er indlæst..."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Indstillinger for logbog",
|
||||
"subtitle": "Del, tag backup og samarbejd om denne logbog.",
|
||||
"select_logbook_hint": "Vælg en logbog for at redigere dens indstillinger.",
|
||||
"no_key": "Ingen OpenWeatherMap API-nøgle tilgængelig. Gem din egen nøgle i brugerprofilen, eller kontakt operatøren.",
|
||||
"weather_success": "Vejrdata hentet med succes!",
|
||||
"weather_error": "Hentning af vejrdata mislykkedes. Tjek API-nøglen og forbindelsen.",
|
||||
"weather_date_mismatch": "Vejrdata kan kun hentes for i dag ({{today}}). Dette logbogsindlæg er dateret {{date}}.",
|
||||
"gps_error": "Indtast en placering, eller find GPS-koordinaterne.",
|
||||
"share_title": "Del logbog (skrivebeskyttet)",
|
||||
"share_desc": "Aktivér denne mulighed for at oprette et offentligt, skrivebeskyttet link. Alle med linket kan se dine rejser, yachtprofiler og crew. Krypteringsnøglerne overføres aldrig til serveren (de forbliver i hash-delen af URL'en).",
|
||||
"share_privacy_warning": "Anbefaling: Del kun dette link privat (f.eks. via e-mail eller messenger), ikke på sociale medier.",
|
||||
"share_enable": "Aktivér offentligt link",
|
||||
"share_copied": "Link kopieret!",
|
||||
"share_copy_btn": "Kopier link",
|
||||
"link_qr_hint": "Scan QR-koden med din telefon",
|
||||
"link_qr_alt": "QR-kode til linket",
|
||||
"danger_zone_title": "Farezone",
|
||||
"danger_zone_desc": "Når du sletter din konto, slettes alle dine Passkey'er, logbøger, skibsdata, Crew-profiler, rejseindlæg og E2E-nøgler uigenkaldeligt. Denne handling kan ikke fortrydes.",
|
||||
"delete_account_btn": "Slet konto uigenkaldeligt",
|
||||
"delete_account_confirm_title": "Slette konto?",
|
||||
"delete_account_confirm_desc": "Er du helt sikker på, at du vil slette din konto uigenkaldeligt og alle tilknyttede logbøger og E2E-krypterede data?",
|
||||
"delete_account_confirm_yes": "Ja, slet konto og alle data",
|
||||
"delete_account_confirm_no": "Annuller",
|
||||
"delete_account_failed": "Kontoen kunne ikke slettes. Prøv venligst igen.",
|
||||
"delete_backup_hint": "Tip: Lav sikkerhedskopier af dine logbøger (.daagbok.json) i indstillingerne for hver logbog, før du sletter dem.",
|
||||
"deleting_account": "Kontoen vil blive slettet...",
|
||||
"invite_push_prompt_title": "Aktivere push-meddelelser?",
|
||||
"invite_push_prompt_message": "Så snart inviterede Crew-medlemmer synkroniserer ændringer, kan du blive informeret via push. Intet logbogsindhold sendes i almindelig tekst.",
|
||||
"invite_push_prompt_ios_message": "Så snart Crew-medlemmerne synkroniserer ændringer, kan du blive informeret via push. På iPhone/iPad: Føj appen til startskærmen (iOS 16.4+), og aktiver derefter push i brugerprofilen.",
|
||||
"invite_push_prompt_enable": "Aktiver nu",
|
||||
"invite_push_prompt_later": "Senere",
|
||||
"invite_push_prompt_success": "Push-meddelelser er aktive på denne enhed.",
|
||||
"backup_title": "Sikkerhedskopiering og gendannelse",
|
||||
"backup_desc": "Komplet krypteret backup af denne logbog (poster, fotos, GPS-spor, crew, skib). Beskyttet med backup-passphrase - til gendannelse til denne eller en ny konto.",
|
||||
"backup_export_title": "Opret backup",
|
||||
"backup_export_desc": "Downloader alle lokale data som .daagbok.json. Hold filen og adgangssætningen adskilt og sikker.",
|
||||
"backup_restore_title": "Gendan sikkerhedskopi",
|
||||
"backup_restore_desc": "Gendanner en sikkerhedskopi til din nuværende konto - selv efter registrering af en ny konto.",
|
||||
"backup_passphrase": "Backup-passphrase",
|
||||
"backup_passphrase_placeholder": "Mindst 8 tegn",
|
||||
"backup_passphrase_confirm": "Bekræft adgangssætning",
|
||||
"backup_passphrase_short": "Backup-passphrasen skal være mindst 8 tegn lang.",
|
||||
"backup_passphrase_mismatch": "Passphrases matcher ikke.",
|
||||
"backup_wrong_passphrase": "Passphrase forkert eller backup beskadiget.",
|
||||
"backup_export_btn": "Download backup",
|
||||
"backup_exporting": "Sikkerhedskopien er oprettet...",
|
||||
"backup_export_success": "Backup oprettet ({{count}} rejsedage).",
|
||||
"backup_file_label": "Backup-fil (.daagbok.json)",
|
||||
"backup_preview_btn": "Tjek indhold",
|
||||
"backup_previewing": "Tjek...",
|
||||
"backup_restore_btn": "Gendan",
|
||||
"backup_restoring": "Vil blive restaureret...",
|
||||
"backup_restore_success": "Logbog \"{{title}}\" er blevet gendannet.",
|
||||
"backup_restore_cancelled": "Genopretning aflyst.",
|
||||
"backup_invalid_json": "Filen er ikke en gyldig JSON-fil.",
|
||||
"backup_invalid_format": "Ukendt eller forældet backup-format.",
|
||||
"backup_not_owner": "Kun logbogens ejer kan oprette sikkerhedskopier.",
|
||||
"backup_not_authenticated": "Log ind for at gendanne en sikkerhedskopi.",
|
||||
"backup_id_conflict": "Der findes allerede en logbog med dette ID.",
|
||||
"backup_overwrite_confirm": "Den eksisterende logbog med samme ID erstattes. Fortsætter du?",
|
||||
"backup_new_id_confirm": "Importere backup'en som en ny logbog med et nyt ID?",
|
||||
"backup_stat_entries": "{{count}} Rejsedage",
|
||||
"backup_stat_photos": "{{count}} Fotos",
|
||||
"backup_stat_crew": "{{count}} Crew-poster",
|
||||
"backup_stat_tracks": "{{count}} GPS-spor",
|
||||
"backup_exported_at": "Eksporteret: {{date}}"
|
||||
},
|
||||
"disclaimer": {
|
||||
"title": "Vigtige bemærkninger",
|
||||
"intro": "Læs venligst følgende instruktioner, før du bruger Kapteins Daagbok.",
|
||||
"e2e_title": "Ende-til-ende-kryptering",
|
||||
"e2e_body": "Dine logbogsdata er krypteret fra ende til anden. Kun du - eller personer med din nøgle - kan læse indholdet. Kun krypterede data gemmes på serveren.",
|
||||
"pwa_title": "Progressiv web-app (PWA)",
|
||||
"pwa_body": "Kapteins Daagbok kører som en progressiv webapp i din browser og kan installeres på din enhed - på samme måde som en native app, men uden en app store.",
|
||||
"storage_title": "Lokal lagring og synkronisering",
|
||||
"storage_body": "Dine data gemmes lokalt på din enhed (IndexedDB). Ændringer synkroniseres med serveren, når en internetforbindelse er aktiv. Du kan arbejde videre uden forbindelse; synkroniseringen finder sted senere.",
|
||||
"free_title": "Gratis og uden reklamer",
|
||||
"free_body": "Kapteins Daagbok er gratis og indeholder ingen reklamer.",
|
||||
"liability_title": "Ansvarsfraskrivelse",
|
||||
"liability_body": "Brug af appen sker på egen risiko. Vi påtager os intet ansvar for skader, der opstår som følge af brugen af appen - herunder forkerte eller ufuldstændige logbogsindførsler, tab af data eller tekniske fejl.",
|
||||
"warranty_title": "Ingen garanti",
|
||||
"warranty_body": "Der gives ingen garanti for tjenestens funktion, korrekthed eller tilgængelighed. Driften kan til enhver tid blive afbrudt, begrænset eller annulleret.",
|
||||
"copyright": "© 2026 KnorrLabs, Markus F.J. Busche",
|
||||
"accept": "Accepter og fortsæt",
|
||||
"close": "Luk",
|
||||
"button_title": "Noter og ansvarsfraskrivelse"
|
||||
},
|
||||
"feedback": {
|
||||
"button_title": "Send feedback",
|
||||
"title": "Feedback",
|
||||
"intro": "Del fejl, ideer eller generel feedback. Din besked vil blive sendt til projektteamet via en sikker meddelelseskanal.",
|
||||
"category_label": "Kategori",
|
||||
"category_general": "Generelt",
|
||||
"category_bug": "Rapporter fejl",
|
||||
"category_feature": "Anmodning om funktion",
|
||||
"category_translation": "Oversættelsesfejl",
|
||||
"contact_label": "E-mail (valgfrit)",
|
||||
"contact_placeholder": "deine@email.beispiel",
|
||||
"message_label": "Besked",
|
||||
"message_placeholder": "Beskriv din feedback...",
|
||||
"send": "Send",
|
||||
"sending": "Vil blive sendt...",
|
||||
"cancel": "Annuller",
|
||||
"success": "Tusind tak skal du have! Din feedback er blevet sendt.",
|
||||
"error_send": "Feedback kunne ikke sendes. Prøv venligst igen senere.",
|
||||
"error_invalid_email": "Indtast venligst en gyldig e-mailadresse.",
|
||||
"error_not_configured": "Feedback er ikke tilgængelig på denne server.",
|
||||
"error_rate_limited": "For mange tilbagemeldinger på kort tid. Vent venligst et par minutter.",
|
||||
"error_spam": "Denne besked kunne ikke sendes. Vær venlig at omformulere den."
|
||||
},
|
||||
"demo": {
|
||||
"logbook_title": "Demo-logbog Østersøen",
|
||||
"badge": "Demo",
|
||||
"public_banner": "Skrivebeskyttet demo-visning",
|
||||
"cta_register": "Opret konto",
|
||||
"back_to_login": "Til registreringen"
|
||||
},
|
||||
"invitation": {
|
||||
"error_invalid_key": "Invitationslinket er kryptografisk ugyldigt (nøglen er forkert).",
|
||||
"error_missing_key": "Invitationslinket indeholder ikke en dekrypteringsnøgle (#key=...). Brug venligst det fulde link fra ejeren.",
|
||||
"error_expired": "Denne invitation er udløbet (gyldig i 48 timer).",
|
||||
"error_invalid_token": "Invitationstokenet er ugyldigt.",
|
||||
"error_load_failed": "Invitationsoplysningerne kunne ikke indlæses.",
|
||||
"error_incomplete_session": "Session ufuldstændig - log venligst ind igen (bruger-ID mangler).",
|
||||
"error_accept_failed": "Tiltrædelse mislykkedes.",
|
||||
"error_login_failed": "Passkey Login mislykkedes.",
|
||||
"error_username_missing": "Brugernavnet kunne ikke bestemmes - log venligst ind igen.",
|
||||
"error_register_failed": "Registrering mislykkedes.",
|
||||
"loading_joining": "At slutte sig til...",
|
||||
"loading_checking": "Invitation vil blive tjekket...",
|
||||
"loading_unlocking": "Logbogen er låst op og synkroniseret...",
|
||||
"loading_retrieving_key": "Download krypteringsnøgle...",
|
||||
"error_title": "Fejl i invitation",
|
||||
"back_to_start": "Tilbage til start",
|
||||
"title": "Invitation til logbog",
|
||||
"invited_by": "Invitation fra",
|
||||
"vessel_logbook": "Skib / Logbog",
|
||||
"signed_in_preparing": "Registreret som {{username}}. Tilslutning er ved at blive forberedt...",
|
||||
"join_again": "Deltag igen",
|
||||
"login_or_register_hint": "Log ind eller opret en konto for at deltage i logbogen.",
|
||||
"or_sign_up": "ELLER REGISTRER DIG IGEN",
|
||||
"register_crew_account": "Opret en ny crew-konto",
|
||||
"username_label": "Brugernavn",
|
||||
"create_passkey": "Opret Passkey.",
|
||||
"switch_language_en": "Engelsk",
|
||||
"switch_language_de": "Tysk"
|
||||
},
|
||||
"stats": {
|
||||
"title": "Statistik",
|
||||
"subtitle": "Overblik over ruter, forbrug og kørselstype",
|
||||
"scope_label": "Evalueringsområde",
|
||||
"scope_logbook": "Denne logbog",
|
||||
"scope_account": "Alle logbøger",
|
||||
"loading": "Statistikkerne er beregnet...",
|
||||
"no_data": "Ingen rejsedage tilgængelige endnu.",
|
||||
"total_distance": "Samlet afstand",
|
||||
"travel_days": "Rejsedage",
|
||||
"sail_distance": "Under sejl",
|
||||
"motor_distance": "Kørsel med maskine",
|
||||
"motor_hours_total": "Samlet antal maskintimer",
|
||||
"daily_motor_hours": "Maskintimer pr. rejsedag",
|
||||
"avg_motor_hours": "Ø maskintimer pr. rejsedag",
|
||||
"unknown_propulsion": "Ukendt",
|
||||
"fuel_total": "Brændstof i alt",
|
||||
"water_total": "Vand i alt",
|
||||
"daily_etmal": "Daglige tider",
|
||||
"daily_consumption": "Dagligt forbrug",
|
||||
"route_overview": "Rute",
|
||||
"route_map_title": "Oversigt over ruter",
|
||||
"propulsion_title": "Sejl vs. maskine",
|
||||
"propulsion_hint": "Opdelingen er baseret på logbogshændelser pr. rejsedag, ikke på GPS-segmenter.",
|
||||
"avg_distance": "Ø pr. rejsedag",
|
||||
"avg_fuel": "Ø Brændstof",
|
||||
"avg_water": "Ø Vand",
|
||||
"fuel_per_nm": "Brændstof pr. sm",
|
||||
"fuel_per_motor_hour": "Brændstof pr. maskintime",
|
||||
"daily_fuel_per_motor_hour": "Brændstofforbrug pr. maskintime pr. rejsedag",
|
||||
"fuel_legend": "Brændstof",
|
||||
"water_legend": "Vand",
|
||||
"unit_nm": "sm",
|
||||
"unit_h": "h",
|
||||
"unit_l": "L",
|
||||
"day_label": "Dag {{day}}",
|
||||
"account_logbooks": "Et overblik over logbøger",
|
||||
"col_logbook": "Logbog",
|
||||
"event_series_title": "Hændelsesforløb",
|
||||
"event_series_hint": "Kronologiske værdier fra hændelsesloggen.",
|
||||
"event_series_pressure": "Lufttryk",
|
||||
"event_series_wind": "Vind",
|
||||
"event_series_motor": "Motor",
|
||||
"event_series_empty": "Ingen indtastninger endnu."
|
||||
},
|
||||
"tour": {
|
||||
"skip": "Spring turen over",
|
||||
"back": "Tilbage",
|
||||
"next": "Yderligere",
|
||||
"finish": "Klar",
|
||||
"progress": "Trin {{current}} fra {{total}}.",
|
||||
"steps": {
|
||||
"welcome": {
|
||||
"title": "Velkommen om bord!",
|
||||
"body": "Vi har lavet en demo-logbog med tre dages rejse i Kielerfjorden til dig. Du kan til enhver tid slette prøveposterne, hvis du vil starte din egen logbog. Denne korte rundvisning viser dig de vigtigste funktioner."
|
||||
},
|
||||
"welcome_public": {
|
||||
"title": "Velkommen om bord!",
|
||||
"body": "Udforsk vores demo-logbog med tre dages rejse i Kielerfjorden – uden konto. Turen viser logbogsposter samt valg af skib og besætning for denne logbog. Flåde og stamm-besætning vedligeholder du senere i brugerprofilen."
|
||||
},
|
||||
"nav_logs": {
|
||||
"title": "Indlæg i logbogen",
|
||||
"body": "Det er her, du styrer dine rejsedage - afgang, destination, vejr, brændstofniveau og GPS-spor."
|
||||
},
|
||||
"entry_list": {
|
||||
"title": "Dine rejsedage",
|
||||
"body": "Hvert kort repræsenterer en rejsedag. Tryk på en post for at se eller redigere detaljer."
|
||||
},
|
||||
"entry_open": {
|
||||
"title": "Åben rejsedag",
|
||||
"body": "Sådan ser et udfyldt logbogsnotat ud - med begivenheder, tankniveauer og meget mere."
|
||||
},
|
||||
"entry_track": {
|
||||
"title": "GPS-spor",
|
||||
"body": "Upload GPX-filer, eller se allerede gemte ruter på kortet - inklusive afstand og hastighed."
|
||||
},
|
||||
"nav_vessel": {
|
||||
"title": "Skib for logbog",
|
||||
"body": "Vælg skib fra flåden for denne logbog. Administrer skibe i brugerprofilen under Flåde og besætning."
|
||||
},
|
||||
"profile_vessel_pool": {
|
||||
"title": "Skibsflåde",
|
||||
"body": "I brugerprofilen opretter du alle dine skibe – charter, eget skib osv. Vælg derefter det rigtige skib per logbog."
|
||||
},
|
||||
"profile_crew_pool": {
|
||||
"title": "Stamm-Crew og skippere",
|
||||
"body": "I brugerprofilen vedligeholder du en personpulje – flere skippere (f.eks. charter) og crew til alle logbøger."
|
||||
},
|
||||
"nav_logbook_crew": {
|
||||
"title": "Crew per logbog",
|
||||
"body": "Vælg skipper og crew fra puljen til denne logbog. Rejsedage arver valget som standard."
|
||||
},
|
||||
"nav_stats": {
|
||||
"title": "Statistik-dashboard",
|
||||
"body": "Her kan du se kørselsafstande, brændstofforbrug, rutekort og kørselsandele - automatisk beregnet ud fra dine logbogsnotater."
|
||||
},
|
||||
"nav_feedback": {
|
||||
"title": "Send feedback",
|
||||
"body": "Du kan bruge denne formular til at sende fejl, ideer eller generel feedback direkte til projektteamet - også efter rundvisningen, når som helst ved hjælp af ikonet øverst til højre."
|
||||
},
|
||||
"nav_profile": {
|
||||
"title": "Din brugerprofil",
|
||||
"body": "Du kan få adgang til din personlige profil via skipperknappen øverst - uanset den aktuelle logbog."
|
||||
},
|
||||
"profile_preferences": {
|
||||
"title": "Regnskab og præsentation",
|
||||
"body": "Her kan du administrere din kontoidentitet, tema og lys/mørk tilstand. Du kan til enhver tid genstarte app-turen. Passkeys og sikkerhedsindstillinger findes længere nede i profilen."
|
||||
},
|
||||
"finish": {
|
||||
"title": "Okay!",
|
||||
"body": "Du vil blive ført direkte til statistikdashboardet. Du kan til enhver tid genstarte turen i din brugerprofil. Hav en god tur!"
|
||||
}
|
||||
}
|
||||
},
|
||||
"seo": {
|
||||
"title": "Kapteins Daagbok - Gratis digital yachtlogbog (reklamefri)",
|
||||
"description": "Gratis, reklamefri digital yachtlogbog med end-to-end-kryptering og Passkey-login. Dokumenter sikkert rejsedage, GPS-spor, Crew- og skibsdata - også offline som PWA.",
|
||||
"keywords": "Yachtlogbog, skibslogbog, logbog om bord, sejlads, Passkey, E2E-kryptering, GPS-spor, maritim logbog, gratis, reklamefri, gratis, uden reklame",
|
||||
"ogImageAlt": "Kapteins Daagbok Logo"
|
||||
}
|
||||
}
|
||||
}
|
||||
+295
-17
@@ -6,16 +6,36 @@
|
||||
"beta": "Beta",
|
||||
"beta_hint": "Beta-Version — Funktionen können sich noch ändern"
|
||||
},
|
||||
"languages": {
|
||||
"de": "Deutsch",
|
||||
"en": "English",
|
||||
"da": "Dansk",
|
||||
"sv": "Svenska",
|
||||
"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": {
|
||||
"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_leave": "Verlassen",
|
||||
"unsaved_changes_stay": "Bleiben"
|
||||
"unsaved_changes_stay": "Bleiben",
|
||||
"unsaved_changes_save_leave": "Speichern & verlassen",
|
||||
"unsaved_changes_discard": "Verwerfen",
|
||||
"unsaved_changes_leave": "Verlassen"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"vessel": "Schiffsdaten",
|
||||
"crew": "Crew-Liste",
|
||||
"crew": "Crew",
|
||||
"deviation": "Ablenkungstabelle",
|
||||
"logs": "Logbucheinträge",
|
||||
"stats": "Statistik",
|
||||
@@ -61,7 +81,12 @@
|
||||
"enter_pin_placeholder": "Gib deine PIN ein...",
|
||||
"decrypt_with_pin": "Entschlüsseln",
|
||||
"use_recovery_instead": "Stattdessen Wiederherstellungsschlüssel verwenden",
|
||||
"error_incorrect_pin": "Falsche PIN. Entschlüsselung fehlgeschlagen."
|
||||
"error_incorrect_pin": "Falsche PIN. Entschlüsselung fehlgeschlagen.",
|
||||
"error_invalid_host": "Passkeys funktionieren nicht über 127.0.0.1. Bitte die App über localhost öffnen.",
|
||||
"use_localhost_link": "Zu localhost wechseln",
|
||||
"error_passkey_cancelled": "Passkey-Anmeldung abgebrochen oder abgelaufen. Bitte erneut versuchen.",
|
||||
"error_invalid_rp_id": "Passkey-Domain passt nicht (RP ID). Lokal nur http://localhost:5173 mit RP_ID=localhost in .env verwenden.",
|
||||
"error_session_incomplete": "Anmeldung unvollständig. Bitte erneut mit Passkey anmelden."
|
||||
},
|
||||
"pwa": {
|
||||
"title": "App installieren",
|
||||
@@ -80,13 +105,18 @@
|
||||
"update_title": "Update verfügbar",
|
||||
"update_desc": "Eine neue Version von Kapteins Daagbok ist bereit. Bitte aktualisieren, um die neuesten Änderungen zu erhalten.",
|
||||
"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": {
|
||||
"status_synced": "Synchronisiert",
|
||||
"status_syncing": "Synchronisiere…",
|
||||
"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": {
|
||||
"title": "Schiffs-Stammdaten",
|
||||
@@ -138,7 +168,8 @@
|
||||
"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.",
|
||||
"date": "Datum",
|
||||
"day_of_travel": "Tag der Reise / Reisetag",
|
||||
"day_of_travel": "Reisetag",
|
||||
"travel_day_number": "Reisetag {{number}}",
|
||||
"departure": "Start-Hafen (Reise von)",
|
||||
"destination": "Ziel-Hafen (nach)",
|
||||
"route": "Reise von/nach",
|
||||
@@ -190,6 +221,98 @@
|
||||
"saving": "Wird gespeichert...",
|
||||
"saved": "Logbuchseite erfolgreich gespeichert!",
|
||||
"loading": "Journal wird geladen...",
|
||||
"view_mode_label": "Ansicht",
|
||||
"view_list": "Liste",
|
||||
"live_mode": "Live",
|
||||
"live_title": "Live-Journal",
|
||||
"live_loading": "Live-Journal wird geladen...",
|
||||
"live_retry": "Erneut versuchen",
|
||||
"live_load_error": "Live-Journal konnte nicht geladen werden.",
|
||||
"live_action_error": "Eintrag konnte nicht gespeichert werden.",
|
||||
"live_open_editor": "Vollständiger Editor",
|
||||
"live_actions_label": "Schnellaktionen",
|
||||
"live_stream_label": "Ereignisprotokoll",
|
||||
"live_stream_title": "Journal",
|
||||
"live_no_events": "Noch keine Einträge — tippe auf eine Aktion.",
|
||||
"live_motor_start": "Motor Start",
|
||||
"live_motor_stop": "Motor Stop",
|
||||
"live_cast_off": "Ablegen",
|
||||
"live_moor": "Anlegen",
|
||||
"live_sails_btn": "Segel",
|
||||
"live_sails_pick": "Segel auswählen",
|
||||
"live_sails_pick_hint": "Mehrere Segel antippen (erneut antippen zum Abwählen), dann Eintragen.",
|
||||
"live_sails_selected": "Auswahl: {{sails}}",
|
||||
"live_sails_confirm": "Eintragen",
|
||||
"live_sails_confirm_count": "Eintragen ({{count}})",
|
||||
"live_sails": "Segel: {{sails}}",
|
||||
"live_fix": "Fix",
|
||||
"live_fix_coords": "Fix {{lat}}, {{lng}}",
|
||||
"live_fix_manual_hint": "GPS nicht verfügbar. Breiten- und Längengrad manuell eingeben oder erneut per GPS-Knopf versuchen.",
|
||||
"live_fix_gps_loading": "GPS-Position wird ermittelt…",
|
||||
"live_fix_invalid": "Bitte gültige Koordinaten eingeben (Breite −90…90, Länge −180…180).",
|
||||
"live_fix_lat_placeholder": "Breite (Lat)",
|
||||
"live_fix_lng_placeholder": "Länge (Lng)",
|
||||
"live_photo_btn": "Foto (Kamera)",
|
||||
"live_photo_capture_btn": "Aufnehmen",
|
||||
"live_photo_save_btn": "Speichern",
|
||||
"live_photo_retake_btn": "Neu aufnehmen",
|
||||
"live_photo_capture_failed": "Aufnahme fehlgeschlagen. Bitte erneut versuchen.",
|
||||
"live_photo_open_camera_btn": "Kamera öffnen",
|
||||
"live_photo_native_hint": "Foto mit der Gerätekamera aufnehmen und anschließend hier speichern.",
|
||||
"live_photo_camera_starting": "Kamera wird gestartet…",
|
||||
"live_photo_camera_denied": "Kamerazugriff verweigert oder nicht verfügbar.",
|
||||
"live_photo_camera_unavailable": "Kamera wird von diesem Browser nicht unterstützt.",
|
||||
"live_photo_error": "Foto konnte nicht gespeichert werden.",
|
||||
"live_photo_entry": "Foto: {{caption}}",
|
||||
"live_photo_entry_plain": "Foto aufgenommen",
|
||||
"live_undo_photo_hint": "Foto gespeichert",
|
||||
"live_comment_btn": "Kommentar",
|
||||
"live_comment_placeholder": "Freitext eingeben…",
|
||||
"live_comment_confirm": "Eintragen",
|
||||
"live_gps_error": "GPS-Position konnte nicht ermittelt werden.",
|
||||
"live_gps_start_hint": "Beginne deine Tagesreise immer mit einem Standort.",
|
||||
"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_visibility_btn": "Sichtweite",
|
||||
"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_visibility_entry": "Sichtweite {{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_visibility_placeholder": "z. B. 10 km",
|
||||
"live_course_placeholder": "z. B. 245",
|
||||
"live_fuel_placeholder": "Nachgefüllte Liter",
|
||||
"live_water_placeholder": "Nachgefüllte Liter",
|
||||
"live_sog_btn": "SOG",
|
||||
"live_stw_btn": "STW",
|
||||
"live_sog_entry": "SOG {{speed}} kn",
|
||||
"live_stw_entry": "STW {{speed}} kn",
|
||||
"live_sog_placeholder": "z. B. 5,2",
|
||||
"live_stw_placeholder": "z. B. 4,8",
|
||||
"live_sog_hint": "Fahrt über Grund (kn) — GPS-Wert wird vorgefüllt, wenn verfügbar.",
|
||||
"delete_entry": "Tag löschen",
|
||||
"delete_confirm": "Bist du sicher, dass du diesen Reisetag unwiderruflich löschen möchtest?",
|
||||
"carry_over_tanks_title": "Daten vom Vortag übernehmen?",
|
||||
@@ -221,6 +344,12 @@
|
||||
"event_wind_direction": "Wind-Richtung",
|
||||
"event_wind_strength": "Windstärke",
|
||||
"event_sea_state": "Seegang",
|
||||
"event_visibility": "Sichtweite",
|
||||
"event_visibility_placeholder": "z. B. 10 km",
|
||||
"weather_slider_unset": "—",
|
||||
"weather_slider_pressure": "{{value}} hPa",
|
||||
"weather_slider_sea_state": "Stufe {{value}}",
|
||||
"weather_slider_heel": "{{value}}°",
|
||||
"event_weather": "Wetter",
|
||||
"event_log": "Logge (sm)",
|
||||
"event_gps": "GPS-Position",
|
||||
@@ -266,6 +395,56 @@
|
||||
"track_map_end": "Ziel",
|
||||
"track_map_speed_slow": "langsam",
|
||||
"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.",
|
||||
"exporting": "Exportiere...",
|
||||
"share_unsupported": "Teilen wird auf diesem Gerät nicht unterstützt. Datei wurde stattdessen heruntergeladen.",
|
||||
@@ -301,10 +480,27 @@
|
||||
"role_read": "Nur Lesen",
|
||||
"role_read_hint": "Geteiltes Logbuch — nur Ansicht, keine Bearbeitung",
|
||||
"open_profile": "Profil von {{name}} öffnen",
|
||||
"open_logbook": "Logbuch „{{title}}“ öffnen",
|
||||
"edit_title": "Logbuch umbenennen",
|
||||
"edit_placeholder": "Neuer Name des Logbuchs",
|
||||
"edit_success": "Logbuch erfolgreich umbenannt",
|
||||
"edit_btn": "Umbenennen"
|
||||
"edit_btn": "Umbenennen",
|
||||
"filter_label": "Logbücher filtern",
|
||||
"filter_placeholder": "Name, Jahr, Datum, Crew oder Schiff …",
|
||||
"filter_clear": "Filter zurücksetzen",
|
||||
"filter_results": "{{count}} Treffer",
|
||||
"filter_no_results": "Keine Logbücher passen zu deiner Suche. Probiere einen anderen Namen oder ein anderes Jahr.",
|
||||
"sort_label": "Sortieren",
|
||||
"sort_by_label": "Sortieren nach",
|
||||
"sort_by_name": "Name",
|
||||
"sort_by_date": "Datum",
|
||||
"sort_dir_label": "Reihenfolge",
|
||||
"sort_asc": "Aufsteigend",
|
||||
"sort_desc": "Absteigend",
|
||||
"sort_name_asc": "Name A bis Z",
|
||||
"sort_name_desc": "Name Z bis A",
|
||||
"sort_date_asc": "Älteste zuerst",
|
||||
"sort_date_desc": "Neueste zuerst"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Benutzerprofil",
|
||||
@@ -421,7 +617,72 @@
|
||||
"push_unsupported": "Push-Benachrichtigungen werden in diesem Browser nicht unterstützt.",
|
||||
"push_denied_hint": "Benachrichtigungen sind blockiert. Erlaube sie in den Browser- oder Geräteeinstellungen.",
|
||||
"push_ios_install_hint": "Auf dem iPhone/iPad: App zum Home-Bildschirm hinzufügen (iOS 16.4+), um Push zu nutzen.",
|
||||
"push_error": "Push-Benachrichtigungen konnten nicht aktiviert werden."
|
||||
"push_error": "Push-Benachrichtigungen konnten nicht aktiviert werden.",
|
||||
"sections": {
|
||||
"account": "Konto & Einstellungen",
|
||||
"fleet": "Flotte & Crew",
|
||||
"security": "Sicherheit & Gerät",
|
||||
"stats": "Statistik",
|
||||
"danger": "Gefahrenzone"
|
||||
}
|
||||
},
|
||||
"vessel_pool": {
|
||||
"title": "Schiffsflotte",
|
||||
"section_title": "Deine Schiffe",
|
||||
"subtitle": "Pflege hier alle Schiffe für deine Logbücher. Pro Logbuch wählst du das aktive Schiff aus dieser Liste.",
|
||||
"loading": "Schiffsflotte wird geladen…",
|
||||
"add_vessel": "Schiff hinzufügen",
|
||||
"edit_vessel": "Schiff bearbeiten",
|
||||
"no_vessels": "Noch keine Schiffe im Pool.",
|
||||
"delete_confirm": "Dieses Schiff wirklich aus der Flotte entfernen?",
|
||||
"max_vessels": "Maximale Anzahl von 20 Schiffen im Pool erreicht."
|
||||
},
|
||||
"logbook_vessel": {
|
||||
"title": "Schiff für dieses Logbuch",
|
||||
"subtitle": "Wähle das Schiff für dieses Logbuch. Reisetage nutzen Segel- und Tankdaten des gewählten Schiffs.",
|
||||
"active_vessel": "Schiff für dieses Logbuch",
|
||||
"no_vessels_in_pool": "Kein Schiff in der Flotte – zuerst im Benutzerprofil anlegen.",
|
||||
"no_vessel": "Kein Schiff gewählt",
|
||||
"unnamed": "Unbenannt",
|
||||
"save": "Schiff speichern",
|
||||
"saved": "Schiff für das Logbuch gespeichert.",
|
||||
"selection_only_hint": "Du siehst das vom Eigner gewählte Schiff (geteiltes Logbuch).",
|
||||
"manage_in_profile": "Schiffe im Benutzerprofil verwalten"
|
||||
},
|
||||
"person_pool": {
|
||||
"title": "Stammcrew & Skipper",
|
||||
"subtitle": "Lege hier deinen Personen-Pool an – Skipper und Crew für alle Logbücher. Aus diesem Pool wählst du pro Logbuch und Reisetag die aktive Crew.",
|
||||
"loading": "Personen-Pool wird geladen…",
|
||||
"skippers_section": "Stammskipper",
|
||||
"crew_section": "Stammcrew",
|
||||
"add_skipper": "Skipper hinzufügen",
|
||||
"add_crew": "Crew-Mitglied hinzufügen",
|
||||
"edit_skipper": "Skipper bearbeiten",
|
||||
"no_skippers": "Noch kein Skipper im Pool.",
|
||||
"no_crew": "Noch keine Crew-Mitglieder im Pool.",
|
||||
"delete_confirm": "Diese Person wirklich aus dem Pool entfernen?"
|
||||
},
|
||||
"logbook_crew": {
|
||||
"title": "Crew für dieses Logbuch",
|
||||
"subtitle": "Wähle Skipper und Crew für dieses Logbuch. Neue Reisetage übernehmen diese Auswahl standardmäßig.",
|
||||
"loading": "Crew wird geladen…",
|
||||
"active_skipper": "Skipper für dieses Logbuch",
|
||||
"active_crew": "Crew für dieses Logbuch",
|
||||
"no_skippers_in_pool": "Kein Skipper im Pool – zuerst im Benutzerprofil anlegen.",
|
||||
"no_crew_in_pool": "Keine Crew im Pool – zuerst im Benutzerprofil anlegen.",
|
||||
"no_skipper": "Kein Skipper gewählt",
|
||||
"unnamed": "Unbenannt",
|
||||
"save": "Crew speichern",
|
||||
"saved": "Crew für das Logbuch gespeichert.",
|
||||
"selection_only_hint": "Du siehst die vom Eigner festgelegte Crew (geteiltes Logbuch)."
|
||||
},
|
||||
"entry_crew": {
|
||||
"title": "Crew an diesem Reisetag",
|
||||
"subtitle": "Kann vom Logbuch-Standard abweichen. Folge-Reisetage übernehmen den Vortag.",
|
||||
"day_skipper": "Skipper an diesem Tag",
|
||||
"day_crew": "Crew an diesem Tag",
|
||||
"no_skipper": "Kein Skipper gewählt",
|
||||
"no_crew": "Keine Crew gewählt"
|
||||
},
|
||||
"crew": {
|
||||
"title": "Skipper- & Crew-Profile",
|
||||
@@ -431,7 +692,7 @@
|
||||
"add_crew": "Crew-Mitglied hinzufügen",
|
||||
"edit_crew": "Crew-Mitglied bearbeiten",
|
||||
"no_crew": "Noch keine Crew-Mitglieder hinzugefügt.",
|
||||
"max_crew": "Maximale Anzahl von 5 Crew-Mitgliedern erreicht.",
|
||||
"max_crew": "Maximale Anzahl von 12 Crew-Mitgliedern im Pool erreicht.",
|
||||
"name": "Name",
|
||||
"address": "Anschrift",
|
||||
"birthdate": "Geburtstag",
|
||||
@@ -472,6 +733,8 @@
|
||||
"share_enable": "Öffentlichen Link aktivieren",
|
||||
"share_copied": "Link kopiert!",
|
||||
"share_copy_btn": "Link kopieren",
|
||||
"link_qr_hint": "QR-Code zum Scannen mit dem Smartphone",
|
||||
"link_qr_alt": "QR-Code für den Link",
|
||||
"danger_zone_title": "Gefahrenzone",
|
||||
"danger_zone_desc": "Durch das Löschen deines Kontos werden alle deine Passkeys, Logbücher, Schiffsdaten, Crew-Profile, Reiseeinträge und E2E-Schlüssel unwiderruflich gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"delete_account_btn": "Konto unwiderruflich löschen",
|
||||
@@ -551,6 +814,7 @@
|
||||
"category_general": "Allgemein",
|
||||
"category_bug": "Fehler melden",
|
||||
"category_feature": "Feature-Wunsch",
|
||||
"category_translation": "Übersetzungsfehler",
|
||||
"contact_label": "E-Mail (optional)",
|
||||
"contact_placeholder": "deine@email.beispiel",
|
||||
"message_label": "Nachricht",
|
||||
@@ -639,7 +903,13 @@
|
||||
"unit_l": "L",
|
||||
"day_label": "Tag {{day}}",
|
||||
"account_logbooks": "Logbücher im Überblick",
|
||||
"col_logbook": "Logbuch"
|
||||
"col_logbook": "Logbuch",
|
||||
"event_series_title": "Ereignis-Verläufe",
|
||||
"event_series_hint": "Chronologische Werte aus dem Ereignisprotokoll.",
|
||||
"event_series_pressure": "Luftdruck",
|
||||
"event_series_wind": "Wind",
|
||||
"event_series_motor": "Motor",
|
||||
"event_series_empty": "Keine Einträge vorhanden."
|
||||
},
|
||||
"tour": {
|
||||
"skip": "Tour überspringen",
|
||||
@@ -654,7 +924,7 @@
|
||||
},
|
||||
"welcome_public": {
|
||||
"title": "Willkommen an Bord!",
|
||||
"body": "Erkunde unser Demo-Logbuch mit drei Reisetagen in der Kieler Förde – ganz ohne Account. Diese kurze Tour zeigt dir Schiffsdaten, Crew und Logbucheinträge."
|
||||
"body": "Erkunde unser Demo-Logbuch mit drei Reisetagen in der Kieler Förde – ganz ohne Account. Die Tour zeigt dir Logbucheinträge, die Schiff- und Crew-Auswahl für dieses Logbuch. Flotte und Stammcrew pflegst du später im Benutzerprofil."
|
||||
},
|
||||
"nav_logs": {
|
||||
"title": "Logbucheinträge",
|
||||
@@ -673,12 +943,20 @@
|
||||
"body": "Lade GPX-Dateien hoch oder sieh bereits gespeicherte Routen auf der Karte – inklusive Distanz und Geschwindigkeit."
|
||||
},
|
||||
"nav_vessel": {
|
||||
"title": "Schiffsdaten",
|
||||
"body": "Hinterlege Name, Maße und technische Daten deiner Yacht – einmal ausfüllen, für alle Reisetage verfügbar."
|
||||
"title": "Schiff fürs Logbuch",
|
||||
"body": "Wähle aus deiner Schiffsflotte das Schiff für dieses Logbuch. Schiffe pflegst du im Benutzerprofil unter Flotte & Crew."
|
||||
},
|
||||
"nav_crew": {
|
||||
"title": "Crew-Liste",
|
||||
"body": "Verwalte Besatzungsmitglieder und weise sie später Reisetagen zu."
|
||||
"profile_vessel_pool": {
|
||||
"title": "Schiffsflotte",
|
||||
"body": "Im Benutzerprofil legst du alle deine Schiffe an – Charteryachten, eigenes Boot usw. Pro Logbuch wählst du dann das passende Schiff."
|
||||
},
|
||||
"profile_crew_pool": {
|
||||
"title": "Stammcrew & Skipper",
|
||||
"body": "Im Benutzerprofil pflegst du deinen Personen-Pool – mehrere Skipper (z. B. Charter) und Crew-Mitglieder für alle Logbücher."
|
||||
},
|
||||
"nav_logbook_crew": {
|
||||
"title": "Crew pro Logbuch",
|
||||
"body": "Wähle aus dem Pool, wer auf diesem Logbuch als Skipper und Crew gilt. Reisetage übernehmen diese Auswahl standardmäßig."
|
||||
},
|
||||
"nav_stats": {
|
||||
"title": "Statistik-Dashboard",
|
||||
|
||||
+295
-17
@@ -6,16 +6,36 @@
|
||||
"beta": "Beta",
|
||||
"beta_hint": "Beta release — features may still change"
|
||||
},
|
||||
"languages": {
|
||||
"de": "Deutsch",
|
||||
"en": "English",
|
||||
"da": "Dansk",
|
||||
"sv": "Svenska",
|
||||
"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": {
|
||||
"unsaved_changes_title": "Unsaved changes",
|
||||
"unsaved_changes_message": "You have unsaved changes. Leave this page anyway? Your changes will be lost.",
|
||||
"unsaved_changes_leave": "Leave",
|
||||
"unsaved_changes_stay": "Stay"
|
||||
"unsaved_changes_stay": "Stay",
|
||||
"unsaved_changes_save_leave": "Save & leave",
|
||||
"unsaved_changes_discard": "Discard",
|
||||
"unsaved_changes_leave": "Leave"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"vessel": "Vessel Profile",
|
||||
"crew": "Crew List",
|
||||
"crew": "Crew",
|
||||
"deviation": "Deviation Table",
|
||||
"logs": "Logbook Entries",
|
||||
"stats": "Statistics",
|
||||
@@ -61,7 +81,12 @@
|
||||
"enter_pin_placeholder": "Enter your PIN...",
|
||||
"decrypt_with_pin": "Decrypt",
|
||||
"use_recovery_instead": "Use recovery phrase instead",
|
||||
"error_incorrect_pin": "Incorrect PIN. Decryption failed."
|
||||
"error_incorrect_pin": "Incorrect PIN. Decryption failed.",
|
||||
"error_invalid_host": "Passkeys do not work on 127.0.0.1. Please open the app via localhost.",
|
||||
"use_localhost_link": "Switch to localhost",
|
||||
"error_passkey_cancelled": "Passkey sign-in was cancelled or timed out. Please try again.",
|
||||
"error_invalid_rp_id": "Passkey domain mismatch (RP ID). For local dev use http://localhost:5173 with RP_ID=localhost in .env.",
|
||||
"error_session_incomplete": "Sign-in incomplete. Please sign in with your passkey again."
|
||||
},
|
||||
"pwa": {
|
||||
"title": "Install app",
|
||||
@@ -80,13 +105,18 @@
|
||||
"update_title": "Update available",
|
||||
"update_desc": "A new version of Kapteins Daagbok is ready. Reload to get the latest changes.",
|
||||
"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": {
|
||||
"status_synced": "Synced",
|
||||
"status_syncing": "Syncing…",
|
||||
"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": {
|
||||
"title": "Vessel Master Data",
|
||||
@@ -138,7 +168,8 @@
|
||||
"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.",
|
||||
"date": "Date",
|
||||
"day_of_travel": "Day of Travel",
|
||||
"day_of_travel": "Travel day",
|
||||
"travel_day_number": "Travel day {{number}}",
|
||||
"departure": "Departure Port (von)",
|
||||
"destination": "Destination Port (nach)",
|
||||
"route": "Route / Journey",
|
||||
@@ -190,6 +221,98 @@
|
||||
"saving": "Saving...",
|
||||
"saved": "Logbook page saved successfully!",
|
||||
"loading": "Loading journal...",
|
||||
"view_mode_label": "View",
|
||||
"view_list": "List",
|
||||
"live_mode": "Live",
|
||||
"live_title": "Live Journal",
|
||||
"live_loading": "Loading live journal...",
|
||||
"live_retry": "Try again",
|
||||
"live_load_error": "Could not load live journal.",
|
||||
"live_action_error": "Could not save entry.",
|
||||
"live_open_editor": "Full editor",
|
||||
"live_actions_label": "Quick actions",
|
||||
"live_stream_label": "Event log",
|
||||
"live_stream_title": "Journal",
|
||||
"live_no_events": "No entries yet — tap an action.",
|
||||
"live_motor_start": "Engine Start",
|
||||
"live_motor_stop": "Engine Stop",
|
||||
"live_cast_off": "Cast off",
|
||||
"live_moor": "Moor",
|
||||
"live_sails_btn": "Sails",
|
||||
"live_sails_pick": "Select sails",
|
||||
"live_sails_pick_hint": "Tap multiple sails (tap again to deselect), then log.",
|
||||
"live_sails_selected": "Selected: {{sails}}",
|
||||
"live_sails_confirm": "Log entry",
|
||||
"live_sails_confirm_count": "Log entry ({{count}})",
|
||||
"live_sails": "Sails: {{sails}}",
|
||||
"live_fix": "Fix",
|
||||
"live_fix_coords": "Fix {{lat}}, {{lng}}",
|
||||
"live_fix_manual_hint": "GPS unavailable. Enter latitude and longitude manually, or try again with the GPS button.",
|
||||
"live_fix_gps_loading": "Getting GPS position…",
|
||||
"live_fix_invalid": "Please enter valid coordinates (latitude −90…90, longitude −180…180).",
|
||||
"live_fix_lat_placeholder": "Latitude (Lat)",
|
||||
"live_fix_lng_placeholder": "Longitude (Lng)",
|
||||
"live_photo_btn": "Photo (camera)",
|
||||
"live_photo_capture_btn": "Capture",
|
||||
"live_photo_save_btn": "Save",
|
||||
"live_photo_retake_btn": "Retake",
|
||||
"live_photo_capture_failed": "Capture failed. Please try again.",
|
||||
"live_photo_open_camera_btn": "Open camera",
|
||||
"live_photo_native_hint": "Take a photo with your device camera, then save it here.",
|
||||
"live_photo_camera_starting": "Starting camera…",
|
||||
"live_photo_camera_denied": "Camera access denied or unavailable.",
|
||||
"live_photo_camera_unavailable": "Camera is not supported in this browser.",
|
||||
"live_photo_error": "Could not save photo.",
|
||||
"live_photo_entry": "Photo: {{caption}}",
|
||||
"live_photo_entry_plain": "Photo captured",
|
||||
"live_undo_photo_hint": "Photo saved",
|
||||
"live_comment_btn": "Comment",
|
||||
"live_comment_placeholder": "Enter text…",
|
||||
"live_comment_confirm": "Log entry",
|
||||
"live_gps_error": "Could not determine GPS position.",
|
||||
"live_gps_start_hint": "Always start your day's voyage with a position fix.",
|
||||
"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_visibility_btn": "Visibility",
|
||||
"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_visibility_entry": "Visibility {{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_visibility_placeholder": "e.g. 10 km",
|
||||
"live_course_placeholder": "e.g. 245",
|
||||
"live_fuel_placeholder": "Liters refilled",
|
||||
"live_water_placeholder": "Liters refilled",
|
||||
"live_sog_btn": "SOG",
|
||||
"live_stw_btn": "STW",
|
||||
"live_sog_entry": "SOG {{speed}} kn",
|
||||
"live_stw_entry": "STW {{speed}} kn",
|
||||
"live_sog_placeholder": "e.g. 5.2",
|
||||
"live_stw_placeholder": "e.g. 4.8",
|
||||
"live_sog_hint": "Speed over ground (kn) — prefilled from GPS when available.",
|
||||
"delete_entry": "Delete Day",
|
||||
"delete_confirm": "Are you sure you want to permanently delete this travel day?",
|
||||
"carry_over_tanks_title": "Carry over from previous day?",
|
||||
@@ -221,6 +344,12 @@
|
||||
"event_wind_direction": "Wind Dir",
|
||||
"event_wind_strength": "Wind Str",
|
||||
"event_sea_state": "Sea State",
|
||||
"event_visibility": "Visibility",
|
||||
"event_visibility_placeholder": "e.g. 10 km",
|
||||
"weather_slider_unset": "—",
|
||||
"weather_slider_pressure": "{{value}} hPa",
|
||||
"weather_slider_sea_state": "State {{value}}",
|
||||
"weather_slider_heel": "{{value}}°",
|
||||
"event_weather": "Weather",
|
||||
"event_log": "Log (nm)",
|
||||
"event_gps": "GPS Position",
|
||||
@@ -266,6 +395,56 @@
|
||||
"track_map_end": "End",
|
||||
"track_map_speed_slow": "slow",
|
||||
"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.",
|
||||
"exporting": "Exporting...",
|
||||
"share_unsupported": "Web sharing is not supported on this device. File downloaded instead.",
|
||||
@@ -301,10 +480,27 @@
|
||||
"role_read": "Read only",
|
||||
"role_read_hint": "Shared logbook — view only, no editing",
|
||||
"open_profile": "Open profile for {{name}}",
|
||||
"open_logbook": "Open logbook “{{title}}”",
|
||||
"edit_title": "Rename Logbook",
|
||||
"edit_placeholder": "New name of the logbook",
|
||||
"edit_success": "Logbook renamed successfully",
|
||||
"edit_btn": "Rename"
|
||||
"edit_btn": "Rename",
|
||||
"filter_label": "Filter logbooks",
|
||||
"filter_placeholder": "Name, year, date, crew or vessel …",
|
||||
"filter_clear": "Clear filter",
|
||||
"filter_results": "{{count}} matches",
|
||||
"filter_no_results": "No logbooks match your search. Try a different name or year.",
|
||||
"sort_label": "Sort",
|
||||
"sort_by_label": "Sort by",
|
||||
"sort_by_name": "Name",
|
||||
"sort_by_date": "Date",
|
||||
"sort_dir_label": "Order",
|
||||
"sort_asc": "Ascending",
|
||||
"sort_desc": "Descending",
|
||||
"sort_name_asc": "Name A to Z",
|
||||
"sort_name_desc": "Name Z to A",
|
||||
"sort_date_asc": "Oldest first",
|
||||
"sort_date_desc": "Newest first"
|
||||
},
|
||||
"profile": {
|
||||
"title": "User profile",
|
||||
@@ -421,7 +617,72 @@
|
||||
"push_unsupported": "Push notifications are not supported in this browser.",
|
||||
"push_denied_hint": "Notifications are blocked. Allow them in your browser or device settings.",
|
||||
"push_ios_install_hint": "On iPhone/iPad: add the app to your Home Screen (iOS 16.4+) to use push notifications.",
|
||||
"push_error": "Could not enable push notifications."
|
||||
"push_error": "Could not enable push notifications.",
|
||||
"sections": {
|
||||
"account": "Account & settings",
|
||||
"fleet": "Fleet & crew",
|
||||
"security": "Security & device",
|
||||
"stats": "Statistics",
|
||||
"danger": "Danger zone"
|
||||
}
|
||||
},
|
||||
"vessel_pool": {
|
||||
"title": "Vessel fleet",
|
||||
"section_title": "Your vessels",
|
||||
"subtitle": "Maintain all vessels for your logbooks here. Select the active vessel per logbook from this list.",
|
||||
"loading": "Loading vessel fleet…",
|
||||
"add_vessel": "Add vessel",
|
||||
"edit_vessel": "Edit vessel",
|
||||
"no_vessels": "No vessels in the pool yet.",
|
||||
"delete_confirm": "Remove this vessel from the fleet?",
|
||||
"max_vessels": "Maximum of 20 vessels in the pool reached."
|
||||
},
|
||||
"logbook_vessel": {
|
||||
"title": "Vessel for this logbook",
|
||||
"subtitle": "Choose the vessel for this logbook. Travel days use sails and tank data from the selected vessel.",
|
||||
"active_vessel": "Vessel for this logbook",
|
||||
"no_vessels_in_pool": "No vessel in the fleet — add one in your user profile first.",
|
||||
"no_vessel": "No vessel selected",
|
||||
"unnamed": "Unnamed",
|
||||
"save": "Save vessel",
|
||||
"saved": "Logbook vessel saved.",
|
||||
"selection_only_hint": "You see the vessel chosen by the owner (shared logbook).",
|
||||
"manage_in_profile": "Manage vessels in user profile"
|
||||
},
|
||||
"person_pool": {
|
||||
"title": "Core Crew & skippers",
|
||||
"subtitle": "Maintain your person pool here — skippers and crew for all logbooks. Select active crew per logbook and travel day from this pool.",
|
||||
"loading": "Loading person pool…",
|
||||
"skippers_section": "Skippers",
|
||||
"crew_section": "Core Crew",
|
||||
"add_skipper": "Add skipper",
|
||||
"add_crew": "Add crew member",
|
||||
"edit_skipper": "Edit skipper",
|
||||
"no_skippers": "No skippers in the pool yet.",
|
||||
"no_crew": "No crew members in the pool yet.",
|
||||
"delete_confirm": "Remove this person from the pool?"
|
||||
},
|
||||
"logbook_crew": {
|
||||
"title": "Crew for this logbook",
|
||||
"subtitle": "Choose skipper and crew for this logbook. New travel days inherit this selection by default.",
|
||||
"loading": "Loading crew…",
|
||||
"active_skipper": "Skipper for this logbook",
|
||||
"active_crew": "Crew for this logbook",
|
||||
"no_skippers_in_pool": "No skipper in the pool — add one in your user profile first.",
|
||||
"no_crew_in_pool": "No crew in the pool — add members in your user profile first.",
|
||||
"no_skipper": "No skipper selected",
|
||||
"unnamed": "Unnamed",
|
||||
"save": "Save crew",
|
||||
"saved": "Logbook crew saved.",
|
||||
"selection_only_hint": "You see the crew set by the owner (shared logbook)."
|
||||
},
|
||||
"entry_crew": {
|
||||
"title": "Crew on this travel day",
|
||||
"subtitle": "May differ from the logbook default. Following days inherit from the previous day.",
|
||||
"day_skipper": "Skipper on this day",
|
||||
"day_crew": "Crew on this day",
|
||||
"no_skipper": "No skipper selected",
|
||||
"no_crew": "No crew selected"
|
||||
},
|
||||
"crew": {
|
||||
"title": "Skipper & Crew Profiles",
|
||||
@@ -431,7 +692,7 @@
|
||||
"add_crew": "Add Crew Member",
|
||||
"edit_crew": "Edit Crew Member",
|
||||
"no_crew": "No crew members added yet.",
|
||||
"max_crew": "Maximum of 5 crew members reached.",
|
||||
"max_crew": "Maximum of 12 crew members in the pool reached.",
|
||||
"name": "Full Name",
|
||||
"address": "Address",
|
||||
"birthdate": "Date of Birth",
|
||||
@@ -472,6 +733,8 @@
|
||||
"share_enable": "Enable Public Link",
|
||||
"share_copied": "Link copied!",
|
||||
"share_copy_btn": "Copy Link",
|
||||
"link_qr_hint": "Scan this QR code with your phone",
|
||||
"link_qr_alt": "QR code for the link",
|
||||
"danger_zone_title": "Danger Zone",
|
||||
"danger_zone_desc": "Deleting your account will permanently delete all your passkeys, logbooks, vessel data, crew profiles, travel logs, and E2E keys. This action cannot be undone.",
|
||||
"delete_account_btn": "Permanently Delete Account",
|
||||
@@ -551,6 +814,7 @@
|
||||
"category_general": "General",
|
||||
"category_bug": "Bug report",
|
||||
"category_feature": "Feature request",
|
||||
"category_translation": "Translation error",
|
||||
"contact_label": "Email (optional)",
|
||||
"contact_placeholder": "your@email.example",
|
||||
"message_label": "Message",
|
||||
@@ -639,7 +903,13 @@
|
||||
"unit_l": "L",
|
||||
"day_label": "Day {{day}}",
|
||||
"account_logbooks": "Logbooks overview",
|
||||
"col_logbook": "Logbook"
|
||||
"col_logbook": "Logbook",
|
||||
"event_series_title": "Event series",
|
||||
"event_series_hint": "Chronological values from the event log.",
|
||||
"event_series_pressure": "Barometric pressure",
|
||||
"event_series_wind": "Wind",
|
||||
"event_series_motor": "Engine",
|
||||
"event_series_empty": "No entries yet."
|
||||
},
|
||||
"tour": {
|
||||
"skip": "Skip tour",
|
||||
@@ -654,7 +924,7 @@
|
||||
},
|
||||
"welcome_public": {
|
||||
"title": "Welcome aboard!",
|
||||
"body": "Explore our demo logbook with three travel days in Kiel Bay — no account required. This short tour shows vessel data, crew, and log entries."
|
||||
"body": "Explore our demo logbook with three travel days in Kiel Bay — no account required. The tour covers log entries and vessel and crew selection for this logbook. Manage your fleet and core crew later in your user profile."
|
||||
},
|
||||
"nav_logs": {
|
||||
"title": "Log entries",
|
||||
@@ -673,12 +943,20 @@
|
||||
"body": "Upload GPX files or view saved routes on the map – including distance and speed stats."
|
||||
},
|
||||
"nav_vessel": {
|
||||
"title": "Vessel data",
|
||||
"body": "Enter your yacht's name, dimensions, and technical details – fill once, use on every travel day."
|
||||
"title": "Vessel for logbook",
|
||||
"body": "Choose a vessel from your fleet for this logbook. Manage vessels in your user profile under Fleet & crew."
|
||||
},
|
||||
"nav_crew": {
|
||||
"title": "Crew list",
|
||||
"body": "Manage crew members and assign them to travel days later."
|
||||
"profile_vessel_pool": {
|
||||
"title": "Vessel fleet",
|
||||
"body": "In your user profile you add all your vessels — charter yachts, your own boat, etc. Then pick the right vessel per logbook."
|
||||
},
|
||||
"profile_crew_pool": {
|
||||
"title": "Core Crew & skippers",
|
||||
"body": "In your user profile you maintain a person pool — multiple skippers (e.g. charter) and crew for all logbooks."
|
||||
},
|
||||
"nav_logbook_crew": {
|
||||
"title": "Crew per logbook",
|
||||
"body": "Pick skipper and crew from the pool for this logbook. Travel days inherit this selection by default."
|
||||
},
|
||||
"nav_stats": {
|
||||
"title": "Statistics dashboard",
|
||||
|
||||
@@ -0,0 +1,990 @@
|
||||
{
|
||||
"translation": {
|
||||
"app": {
|
||||
"name": "Kapteins Daagbok",
|
||||
"tagline": "Loggbok for private båter",
|
||||
"beta": "Beta",
|
||||
"beta_hint": "Betaversjon - funksjoner kan fortsatt endres"
|
||||
},
|
||||
"languages": {
|
||||
"de": "Deutsch",
|
||||
"en": "English",
|
||||
"da": "Dansk",
|
||||
"sv": "Svenska",
|
||||
"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": {
|
||||
"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_stay": "Bli",
|
||||
"unsaved_changes_save_leave": "Lagre og forlat",
|
||||
"unsaved_changes_discard": "Forkast",
|
||||
"unsaved_changes_leave": "Oppgivelse"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashbord",
|
||||
"vessel": "Skipsdata",
|
||||
"crew": "Crew",
|
||||
"deviation": "Tabell over distraksjoner",
|
||||
"logs": "Loggbokoppføringer",
|
||||
"stats": "Statistikk",
|
||||
"settings": "Innstillinger"
|
||||
},
|
||||
"auth": {
|
||||
"welcome": "Velkommen til Kapteins Daagbok",
|
||||
"tagline": "Din sikre, E2E-krypterte maritime loggbok.",
|
||||
"register": "Registrer deg med Passkey",
|
||||
"login": "Logg inn med Passkey",
|
||||
"login_as": "Logg inn som {{name}}",
|
||||
"quick_login": "Rask innlogging",
|
||||
"forget_account": "Glemt konto på denne enheten",
|
||||
"not_user": "Ikke {{name}}?",
|
||||
"recovery_title": "Gjenopprettingsnøkkelen din",
|
||||
"recovery_warning": "VIKTIG: Skriv ned disse 12 ordene. Hvis du mister Passkey og disse ordene, kan du ikke gjenopprette dataene dine.",
|
||||
"confirm_recovery": "Jeg har skrevet ned ordene",
|
||||
"status_logged_in": "Innlogget",
|
||||
"status_logged_out": "Avlyst",
|
||||
"copied": "Oppfattet!",
|
||||
"copy_phrase": "Kopieringstast",
|
||||
"enter_recovery": "Skriv inn gjenopprettingsnøkkel",
|
||||
"recovery_fallback_warning": "Din Passkey har blitt autentisert, men enheten din støtter ikke maskinvarebasert nøkkelderivering. Skriv inn gjenopprettingsnøkkelen på 12 ord for å dekryptere loggboken.",
|
||||
"recovery_placeholder": "Skriv inn gjenopprettingsnøkkelen din, som består av 12 ord atskilt med mellomrom...",
|
||||
"back": "Tilbake",
|
||||
"decrypting": "Dekryptering...",
|
||||
"decrypt_logbook": "Dekryptere loggbok",
|
||||
"error_incorrect_recovery": "Feil gjenopprettingsnøkkel. Dekryptering mislyktes.",
|
||||
"error_decryption_failed": "Dekryptering mislyktes. Vennligst sjekk gjenopprettingsnøkkelen din.",
|
||||
"or_register": "eller registrer deg",
|
||||
"explore_demo": "Utforsk demoen uten konto",
|
||||
"username_placeholder": "Brukernavn / Skippernavn",
|
||||
"processing": "Behandling...",
|
||||
"help": "Hjelp",
|
||||
"setup_pin_title": "Konfigurer lokal PIN-kode (valgfritt)",
|
||||
"setup_pin_warning": "Siden enheten din ikke støtter direkte Passkey-nøkkelavledning, må du ellers skrive inn 12-ordsnøkkelen hver gang du logger deg på denne enheten. Konfigurer en lokal PIN-kode for å unngå dette.",
|
||||
"pin_placeholder": "E.G. 123456",
|
||||
"pin_label": "Lokal PIN-kode (4-8 sifre)",
|
||||
"save_pin": "Lagre PIN-kode og fortsett",
|
||||
"skip_pin": "Hopp over og bruk gjenoppretting",
|
||||
"enter_pin_title": "Dekrypter med PIN-kode",
|
||||
"enter_pin_warning": "Skriv inn din lokale PIN-kode for å låse opp dekrypteringsnøkkelen på denne enheten.",
|
||||
"enter_pin_placeholder": "Tast inn PIN-koden din...",
|
||||
"decrypt_with_pin": "Dekryptere",
|
||||
"use_recovery_instead": "Bruk gjenopprettingsnøkler i stedet",
|
||||
"error_incorrect_pin": "Feil PIN-kode. Dekryptering mislyktes.",
|
||||
"error_invalid_host": "Passkeys fungerer ikke via 127.0.0.1. Åpne appen via localhost.",
|
||||
"use_localhost_link": "Bytt til localhost",
|
||||
"error_passkey_cancelled": "Passkey-innlogging ble avbrutt eller utløp. Prøv igjen.",
|
||||
"error_invalid_rp_id": "Passkey-domene stemmer ikke (RP ID). Bruk http://localhost:5173 med RP_ID=localhost i .env for lokal utvikling.",
|
||||
"error_session_incomplete": "Innlogging ufullstendig. Logg inn med passkey igjen."
|
||||
},
|
||||
"pwa": {
|
||||
"title": "Installer app",
|
||||
"generic_benefit": "Installer Kapteins Daagbok på enheten din for raskere tilgang, frakoblet bruk og permanent lagring av data.",
|
||||
"ios_instructions": "På iPad/iPhone: Legg til appen på startskjermen, slik at loggbokdataene dine forblir beskyttet og appen starter som en vanlig app.",
|
||||
"ios_step_share": "Trykk på aksjesymbolet i Safari-linjen",
|
||||
"ios_step_add": "Velg \"Gå til startskjermen\"",
|
||||
"install_now": "Installer nå",
|
||||
"installing": "Installasjon...",
|
||||
"later": "Senere",
|
||||
"never": "Ikke vis mer",
|
||||
"platform_ios": "Installasjon via Safari",
|
||||
"platform_android": "Installasjon via nettleseren",
|
||||
"platform_desktop": "Installasjon som en desktop-app",
|
||||
"settings_section": "Installasjon av app",
|
||||
"update_title": "Oppdatering tilgjengelig",
|
||||
"update_desc": "En ny versjon av Kapteins Daagbok er klar. Oppdater for å få med de siste endringene.",
|
||||
"update_now": "Oppdater nå",
|
||||
"update_reloading": "Laster...",
|
||||
"storage_persist_hint": "Nettleseren kan slette offlinedata. Tillat permanent lagring slik at loggboken din forblir beskyttet."
|
||||
},
|
||||
"sync": {
|
||||
"status_synced": "Synkronisert",
|
||||
"status_syncing": "Synkroniser...",
|
||||
"status_offline": "Frakoblet hurtigbuffer",
|
||||
"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": {
|
||||
"title": "Stamdata for skip",
|
||||
"name": "Båtens navn",
|
||||
"type": "Båttype",
|
||||
"type_unset": "- ikke spesifisert -",
|
||||
"type_sailing": "Seilbåt",
|
||||
"type_motor": "Motorbåt",
|
||||
"length_m": "Lengde (m)",
|
||||
"draft_m": "Trekkraft (m)",
|
||||
"air_draft_m": "Høyde (m)",
|
||||
"invalid_metric": "Ugyldig tallverdi - angi meter som desimaltall (f.eks. 12,5).",
|
||||
"port": "Hjemmehavn",
|
||||
"owner": "Eier",
|
||||
"charter": "Charterselskap",
|
||||
"registration": "Nummerskilt/registreringsnummer",
|
||||
"callsign": "Radiokallesignal",
|
||||
"atis": "ATIS nr.",
|
||||
"mmsi": "MMSI-nr.",
|
||||
"save": "Lagre skipsdata",
|
||||
"saving": "...vil bli reddet...",
|
||||
"saved": "Skipsdata vellykket lagret!",
|
||||
"loading": "Skipsdata er lastet inn...",
|
||||
"sails_list": "Seil (eksisterende seil)",
|
||||
"sails_help": "Skriv inn seilene som er tilgjengelige på båten din her (f.eks. storseil, genua, fokk).",
|
||||
"add_sail": "Legg til seil",
|
||||
"sail_name_placeholder": "z. f.eks. storseil",
|
||||
"no_sails": "Ingen seil lagret.",
|
||||
"photo_add": "Legg til bilde",
|
||||
"photo_change": "Endre bilde",
|
||||
"photo_delete": "Slett bilde",
|
||||
"tanks_section": "Tanker (kapasitet)",
|
||||
"tanks_help": "Valgfritt i liter - muliggjør glidebryter i tidsskriftet for kjente tankstørrelser.",
|
||||
"freshwater_capacity_l": "Drikkevann (liter)",
|
||||
"fuel_capacity_l": "Drivstoff (liter)",
|
||||
"greywater_capacity_l": "Gråvann (liter)",
|
||||
"invalid_tank_liters": "Ugyldig tallverdi - skriv inn liter som et tall (f.eks. 200)."
|
||||
},
|
||||
"logs": {
|
||||
"title": "Loggbokdagbok",
|
||||
"new_entry": "Ny reisedag",
|
||||
"travel_details": "Detaljer om reisen",
|
||||
"add_event": "Legg til ny loggbokoppføring",
|
||||
"add_event_btn": "Legg til hendelse",
|
||||
"edit_event": "Rediger hendelse",
|
||||
"save_event_btn": "Lagre endring",
|
||||
"cancel_event_edit": "Avbryt",
|
||||
"delete_event": "Slett hendelse",
|
||||
"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.",
|
||||
"date": "dato",
|
||||
"day_of_travel": "Reisedag",
|
||||
"travel_day_number": "Reisedag {{number}}",
|
||||
"departure": "Starthavn (reise fra)",
|
||||
"destination": "Destinasjonsport (til)",
|
||||
"route": "Reise fra/til",
|
||||
"freshwater": "Ferskvann (liter)",
|
||||
"fuel": "Drivstoff / Drivstoff (liter)",
|
||||
"greywater": "Gråvann (liter)",
|
||||
"greywater_level": "Fyllingsnivå",
|
||||
"tank_slider_of_max": "{{current}} / {{max}} L",
|
||||
"tank_capacity_tooltip": "Hvis tankkapasiteten (liter) er lagret i skipsdataene, kan du angi fyllingsnivåene her ved hjelp av glidebryteren.",
|
||||
"morning": "Stå opp om morgenen",
|
||||
"refilled": "Påfyllt",
|
||||
"evening": "Kveldsstand",
|
||||
"consumption": "Daglig forbruk",
|
||||
"signatures": "Underskrifter / frigivelse",
|
||||
"sign_skipper": "Skippers signatur",
|
||||
"sign_crew": "Crews signatur",
|
||||
"sign_hint": "Signer med finger, penn eller mus",
|
||||
"sign_clear": "Slett",
|
||||
"sign_export_image": "[Signatur]",
|
||||
"sign_with_passkey": "Utgivelse med Passkey",
|
||||
"sign_passkey_signing": "Passkey er forespurt...",
|
||||
"sign_passkey_signed": "Utgitt av {{username}}",
|
||||
"sign_passkey_export": "Passkey: {{username}} ({{date}})",
|
||||
"sign_attribution_export": "{{username}} ({{date}})",
|
||||
"sign_passkey_clear": "Fjern Passkey utgivelse",
|
||||
"sign_mode_passkey": "Passkey",
|
||||
"sign_mode_classic": "Klassisk",
|
||||
"sign_passkey_failed": "Passkey Utgivelsen mislyktes",
|
||||
"sign_passkey_cancelled": "Passkey Utgivelse kansellert",
|
||||
"sign_invalid": "Signaturen er ugyldig - innholdet har blitt endret",
|
||||
"sign_badge_skipper": "Skipper",
|
||||
"sign_badge_skipper_invalid": "Ugyldig",
|
||||
"sign_badge_skipper_title_valid": "Skipper har gitt ut",
|
||||
"sign_badge_skipper_title_invalid": "Skippersignaturen er ugyldig - innholdet har blitt endret",
|
||||
"sign_classic_or_passkey": "Valgfritt: klassisk signatur eller Passkey utgivelse ovenfor",
|
||||
"sign_crew_passkey_hint": "Crew-medlemmer med skrivetilgang kan frigjøre via Passkey.",
|
||||
"sign_offline_hint": "Passkey-Godkjenning krever Internett - klassisk signatur mulig offline",
|
||||
"sign_lock_notice": "Etter signering er det ikke mulig å gjøre endringer i loggbokoppføringen (unntatt bilder) uten at skipper og crew må signere på nytt.",
|
||||
"sign_lock_active": "Denne oppføringen er signert. Endringer i loggboken (unntatt bilder) fjerner automatisk skipperens og crews signaturer.",
|
||||
"sign_lock_warning_title": "Bekreft signatur",
|
||||
"sign_lock_warning": "Etter signering er det ikke lenger mulig å gjøre endringer i loggbokoppføringen (unntatt bilder) uten at skipper og crew må signere på nytt.\n\nØnsker du å fortsette?",
|
||||
"sign_proceed": "Skilt",
|
||||
"sign_cancel": "Avbryt",
|
||||
"sign_cleared_re_sign_title": "Signaturer fjernet",
|
||||
"sign_cleared_re_sign": "Loggbokoppføringen har blitt endret. Skipperens og crews signaturer er fjernet. Vennligst signer på nytt.",
|
||||
"no_entries": "Ingen loggbokoppføringer funnet for denne båten. Lag din første seilasdag!",
|
||||
"back_to_list": "Tilbake til tidsskriftlisten",
|
||||
"save": "Lagre loggbokside",
|
||||
"saving": "...vil bli reddet...",
|
||||
"saved": "Loggboksiden er vellykket lagret!",
|
||||
"loading": "Tidsskriftet lastes inn...",
|
||||
"view_mode_label": "Visning",
|
||||
"view_list": "Liste",
|
||||
"live_mode": "Live",
|
||||
"live_title": "Live-journal",
|
||||
"live_loading": "Live-journal lastes inn...",
|
||||
"live_retry": "Prøv igjen",
|
||||
"live_load_error": "Live-journal kunne ikke lastes inn.",
|
||||
"live_action_error": "Oppføringen kunne ikke lagres.",
|
||||
"live_open_editor": "Full editor",
|
||||
"live_actions_label": "Hurtighandlinger",
|
||||
"live_stream_label": "Hendelseslogg",
|
||||
"live_stream_title": "Journal",
|
||||
"live_no_events": "Ingen oppføringer ennå — trykk på en handling.",
|
||||
"live_motor_start": "Motor Start",
|
||||
"live_motor_stop": "Motor Stopp",
|
||||
"live_cast_off": "Avreise",
|
||||
"live_moor": "Anløp",
|
||||
"live_sails_btn": "Seil",
|
||||
"live_sails_pick": "Velg seil",
|
||||
"live_sails_pick_hint": "Trykk flere seil (trykk igjen for å fjerne), deretter loggfør.",
|
||||
"live_sails_selected": "Valgt: {{sails}}",
|
||||
"live_sails_confirm": "Loggfør",
|
||||
"live_sails_confirm_count": "Loggfør ({{count}})",
|
||||
"live_sails": "Seil: {{sails}}",
|
||||
"live_fix": "Fix",
|
||||
"live_fix_coords": "Fix {{lat}}, {{lng}}",
|
||||
"live_fix_manual_hint": "GPS ikke tilgjengelig. Skriv inn bredde- og lengdegrad manuelt, eller prøv igjen med GPS-knappen.",
|
||||
"live_fix_gps_loading": "Henter GPS-posisjon…",
|
||||
"live_fix_invalid": "Skriv inn gyldige koordinater (bredde −90…90, lengde −180…180).",
|
||||
"live_fix_lat_placeholder": "Bredde (Lat)",
|
||||
"live_fix_lng_placeholder": "Lengde (Lng)",
|
||||
"live_photo_btn": "Foto (kamera)",
|
||||
"live_photo_capture_btn": "Ta bilde",
|
||||
"live_photo_save_btn": "Lagre",
|
||||
"live_photo_retake_btn": "Ta på nytt",
|
||||
"live_photo_capture_failed": "Opptak mislyktes. Prøv igjen.",
|
||||
"live_photo_open_camera_btn": "Åpne kamera",
|
||||
"live_photo_native_hint": "Ta et bilde med enhetskameraet og lagre det her etterpå.",
|
||||
"live_photo_camera_starting": "Starter kamera…",
|
||||
"live_photo_camera_denied": "Kameratilgang nektet eller utilgjengelig.",
|
||||
"live_photo_camera_unavailable": "Kamera støttes ikke i denne nettleseren.",
|
||||
"live_photo_error": "Kunne ikke lagre foto.",
|
||||
"live_photo_entry": "Foto: {{caption}}",
|
||||
"live_photo_entry_plain": "Foto tatt",
|
||||
"live_undo_photo_hint": "Foto lagret",
|
||||
"live_comment_btn": "Kommentar",
|
||||
"live_comment_placeholder": "Skriv inn tekst…",
|
||||
"live_comment_confirm": "Loggfør",
|
||||
"live_gps_error": "GPS-posisjon kunne ikke bestemmes.",
|
||||
"live_gps_start_hint": "Start alltid dagsreisen med en posisjon.",
|
||||
"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_visibility_btn": "Sikt",
|
||||
"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_visibility_entry": "Sikt {{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_visibility_placeholder": "f.eks. 10 km",
|
||||
"live_course_placeholder": "f.eks. 245",
|
||||
"live_fuel_placeholder": "Påfylte liter",
|
||||
"live_water_placeholder": "Påfylte liter",
|
||||
"live_sog_btn": "SOG",
|
||||
"live_stw_btn": "STW",
|
||||
"live_sog_entry": "SOG {{speed}} kn",
|
||||
"live_stw_entry": "STW {{speed}} kn",
|
||||
"live_sog_placeholder": "f.eks. 5,2",
|
||||
"live_stw_placeholder": "f.eks. 4,8",
|
||||
"live_sog_hint": "Fart over grunn (kn) — GPS-verdi fylles inn hvis tilgjengelig.",
|
||||
"delete_entry": "Slett tagg",
|
||||
"delete_confirm": "Er du sikker på at du vil slette denne reisedagen permanent?",
|
||||
"carry_over_tanks_title": "Overføre data fra dagen før?",
|
||||
"carry_over_tanks_confirm": "Overta starthavn, ferskvann, drivstoff og gråvann fra startnivåene fra siste dag på turen?\n\nStart havn: {{departure}}\nFerskvann: {{fw}} L\nDrivstoff: {{fuel}} L\nGråvann: {{greywater}} L",
|
||||
"carry_over_tanks_yes": "Ta over",
|
||||
"carry_over_tanks_no": "Begynn med 0",
|
||||
"event_title": "Kronologisk hendelseslogg",
|
||||
"no_events": "Ingen arrangementer lagt inn for denne reisedagen ennå.",
|
||||
"event_time": "Tid på døgnet",
|
||||
"event_mgk": "MgK-kurs",
|
||||
"event_rwk": "RwK-kurs",
|
||||
"event_course_section": "Kurs",
|
||||
"course_dial_hint": "Vri ringen eller angi grader",
|
||||
"course_dial_step_label": "Trinnstørrelse",
|
||||
"course_step_fine": "1°",
|
||||
"course_step_medium": "5°",
|
||||
"course_step_coarse": "10°",
|
||||
"course_tab_mgk": "MgK",
|
||||
"course_tab_rwk": "rwK",
|
||||
"course_invalid": "Ugyldig kurs (0-360)",
|
||||
"course_placeholder_degrees": "z. B. 180",
|
||||
"course_placeholder_cardinal": "z. E.G. NW",
|
||||
"compass_n": "N",
|
||||
"compass_e": "O",
|
||||
"compass_s": "S",
|
||||
"compass_w": "W",
|
||||
"wind_mode_cardinal": "Kardinal",
|
||||
"wind_mode_degrees": "Som grad",
|
||||
"event_wind_direction": "Vindretning",
|
||||
"event_wind_strength": "Vindstyrke",
|
||||
"event_sea_state": "Havets tilstand",
|
||||
"event_visibility": "Sikt",
|
||||
"event_visibility_placeholder": "f.eks. 10 km",
|
||||
"weather_slider_unset": "—",
|
||||
"weather_slider_pressure": "{{value}} hPa",
|
||||
"weather_slider_sea_state": "Grad {{value}}",
|
||||
"weather_slider_heel": "{{value}}°",
|
||||
"event_weather": "Været",
|
||||
"event_log": "Logg (sm)",
|
||||
"event_gps": "GPS-posisjon",
|
||||
"event_location": "Sted / havn",
|
||||
"event_location_placeholder": "z. f.eks. Kiel",
|
||||
"event_remarks": "Merknader / hendelser",
|
||||
"gps_btn": "Hent GPS-koordinater",
|
||||
"weather_btn": "OpenWeatherMap Ring opp været",
|
||||
"event_wind_pressure": "Lufttrykk (hPa)",
|
||||
"event_heel": "Helning (°)",
|
||||
"event_sails": "Seilhåndtering / motor",
|
||||
"motor_propulsion": "Maskinreise",
|
||||
"sails_picker_show_more": "Vis alle seil",
|
||||
"sails_picker_show_less": "Vis mindre",
|
||||
"motor_hours": "Maskintimer (totalt)",
|
||||
"fuel_per_motor_hour": "Forbruk per maskintime",
|
||||
"event_distance": "Avstand (sm)",
|
||||
"export_csv": "Last ned CSV",
|
||||
"share_csv": "CSV andel",
|
||||
"export_pdf": "Last ned PDF",
|
||||
"exporting_pdf": "PDF genereres...",
|
||||
"photos_title": "Bildevedlegg (E2E-kryptert)",
|
||||
"photo_caption_label": "Fotobeskrivelse/etikett (valgfritt)",
|
||||
"photo_caption_placeholder": "f.eks. å sette seil nær innseilingen til havnen",
|
||||
"photo_btn": "Ta bilde / last opp",
|
||||
"photo_processing": "...blir behandlet...",
|
||||
"no_photos": "Ingen bilder knyttet til denne reisedagen ennå.",
|
||||
"photo_delete_confirm": "Er du sikker på at du vil slette dette bildet permanent?",
|
||||
"confirm_yes": "Ja",
|
||||
"confirm_no": "Nei",
|
||||
"track_upload_title": "GPS-sporing (fil)",
|
||||
"track_upload_points": "Poeng",
|
||||
"gps_tracking_btn_gpx": "Last ned sporfil",
|
||||
"gps_track_upload_help": "Dra en GPX-, KML- eller GeoJSON-fil hit, eller klikk for å velge",
|
||||
"gps_track_upload_btn": "Last opp GPS-spor",
|
||||
"gps_track_delete": "Slett sporfil",
|
||||
"gps_track_delete_confirm": "Er du sikker på at du vil slette denne sporfilen permanent?",
|
||||
"track_distance": "GPS-rute (sm)",
|
||||
"track_speed_max": "Maks. Hastighet (kn)",
|
||||
"track_speed_avg": "Ø Hastighet (kn)",
|
||||
"track_map_title": "GPS-spor på OpenSeaMap",
|
||||
"track_map_start": "Start",
|
||||
"track_map_end": "Mål",
|
||||
"track_map_speed_slow": "langsomt",
|
||||
"track_map_speed_fast": "raskt",
|
||||
"track_map_error": "Kartet kunne ikke lastes inn.",
|
||||
"exporting": "Eksport...",
|
||||
"share_unsupported": "Deling støttes ikke på denne enheten. Filen har blitt lastet ned i stedet.",
|
||||
"invite_crew": "Inviter crewet",
|
||||
"invite_link_copied": "Invitasjonslenke kopiert til utklippstavlen!",
|
||||
"invite_link_desc": "Del denne lenken med Crew-medlemmene for å gi dem skrivetilgang til loggboken.",
|
||||
"collaborators_list": "Medlemmer / Crew",
|
||||
"revoke": "Fjern",
|
||||
"revoke_confirm": "Er du sikker på at du vil oppheve dette Crew-medlemmets tilgang?",
|
||||
"invite_role": "Rolle",
|
||||
"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": {
|
||||
"title": "Loggbøkene dine",
|
||||
"subtitle": "Velg en loggbok eller opprett en ny for å administrere reisene dine.",
|
||||
"create_btn": "Opprett loggbok",
|
||||
"new_logbook_placeholder": "Navn på loggboken eller båten",
|
||||
"logout": "Logg ut",
|
||||
"logged_in_as": "Innlogget som {{name}}",
|
||||
"delete_confirm": "Er du sikker på at du vil slette denne loggboken permanent? Alle lokale data og serverkopier vil bli ødelagt.\n\nTips: Lag en sikkerhetskopi (.daagbok.json) på forhånd under Innstillinger → Sikkerhetskopiering og gjenoppretting hvis du ønsker å beholde dataene senere.",
|
||||
"no_logbooks": "Ingen loggbøker funnet. Opprett din første loggbok for å komme i gang!",
|
||||
"loading": "Loggbøker er lastet...",
|
||||
"status_synced": "Synkronisert",
|
||||
"status_local": "Kun lokal hurtigbuffer",
|
||||
"delete_btn": "Slett loggbok",
|
||||
"section_owned": "Loggbøkene mine",
|
||||
"section_shared": "Felles loggbøker",
|
||||
"section_shared_hint": "Du er invitert som Crew-medlem. Skipperprofil og innstillinger tilhører eieren.",
|
||||
"role_owner": "Egen loggbok",
|
||||
"role_owner_hint": "Du er eier og skipper av denne loggboken",
|
||||
"role_crew": "Tilgang for crewet",
|
||||
"role_crew_hint": "Loggbok med invitasjon - du kan jobbe som crew og signere den",
|
||||
"role_read": "Bare les",
|
||||
"role_read_hint": "Delt loggbok - kun visning, ingen redigering",
|
||||
"open_profile": "Åpne profilen til {{name}}",
|
||||
"open_logbook": "Åpne loggbok «{{title}}»",
|
||||
"edit_title": "Endre navn på loggbok",
|
||||
"edit_placeholder": "Nytt navn på loggboken",
|
||||
"edit_success": "Loggboken har fått nytt navn",
|
||||
"edit_btn": "Gi nytt navn",
|
||||
"filter_label": "Filtrer loggbøker",
|
||||
"filter_placeholder": "Navn, årstall, dato, crew eller skip …",
|
||||
"filter_clear": "Tilbakestill filter",
|
||||
"filter_results": "{{count}} Treff",
|
||||
"filter_no_results": "Ingen loggbøker samsvarer med søket ditt. Prøv et annet navn eller et annet år.",
|
||||
"sort_label": "Sortere",
|
||||
"sort_by_label": "Sorter etter",
|
||||
"sort_by_name": "Navn",
|
||||
"sort_by_date": "dato",
|
||||
"sort_dir_label": "Sekvens",
|
||||
"sort_asc": "Stigende",
|
||||
"sort_desc": "Synkende",
|
||||
"sort_name_asc": "Navn A til Å",
|
||||
"sort_name_desc": "Navn Z til A",
|
||||
"sort_date_asc": "Eldst først",
|
||||
"sort_date_desc": "Nyeste først"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Brukerprofil",
|
||||
"subtitle": "Regnskap, Passkeys og statistikk for {{name}}.",
|
||||
"back": "Tilbake til dashbordet",
|
||||
"loading": "Profilen lastes inn...",
|
||||
"load_error": "Profilen kunne ikke lastes inn.",
|
||||
"copy_failed": "Kopien mislyktes.",
|
||||
"processing": "Blir behandlet...",
|
||||
"identity_title": "Kontoidentitet",
|
||||
"username": "Brukernavn",
|
||||
"user_id": "Bruker-ID",
|
||||
"copy_user_id": "Kopier bruker-ID",
|
||||
"account_since": "Konto siden",
|
||||
"prf_status": "Passkey nøkkelavledning (PRF)",
|
||||
"prf_active": "Aktiv",
|
||||
"prf_inactive": "Ikke satt opp",
|
||||
"passkeys_title": "Passkeys",
|
||||
"passkeys_desc": "Registrer en separat Passkey på hver enhet. Dette gjør at du kan logge på selv etter at du har byttet plattform.",
|
||||
"passkeys_empty": "Ingen Passkeyer funnet.",
|
||||
"add_passkey_btn": "Legg til ny Passkey",
|
||||
"add_passkey_success": "Passkey vellykket lagt til.",
|
||||
"add_passkey_failed": "Passkey kunne ikke legges til.",
|
||||
"remove_passkey_btn": "Fjern Passkey",
|
||||
"remove_passkey_last_title": "Sist Passkey",
|
||||
"remove_passkey_last_desc": "Den eneste Passkey kan ikke fjernes uten at du mister tilgangen til kontoen din. For å slette kontoen helt, bruk faresonen nederst på denne siden.",
|
||||
"remove_passkey_failed": "Passkey kunne ikke fjernes.",
|
||||
"remove_passkey_confirm_title": "Fjern Passkey?",
|
||||
"remove_passkey_confirm_desc": "Denne enheten kan da ikke lenger logge inn med denne Passkey.",
|
||||
"remove_passkey_confirm_yes": "Fjern",
|
||||
"remove_passkey_confirm_no": "Avbryt",
|
||||
"pin_title": "Lokal PIN-kode",
|
||||
"pin_status": "Status",
|
||||
"pin_active": "Aktiv på denne enheten",
|
||||
"pin_inactive": "Ikke satt opp",
|
||||
"pin_confirm_label": "Bekreft PIN-kode",
|
||||
"pin_confirm_placeholder": "Tast inn PIN-koden på nytt",
|
||||
"pin_set_btn": "Konfigurer PIN-kode",
|
||||
"pin_change_btn": "Endre PIN-kode",
|
||||
"pin_remove_btn": "Fjern PIN-kode",
|
||||
"pin_saved": "PIN-kode lagret.",
|
||||
"pin_save_failed": "PIN-koden kunne ikke lagres.",
|
||||
"pin_mismatch": "PIN-kodene stemmer ikke overens.",
|
||||
"pin_length_error": "PIN-koden må bestå av minst 4 tegn.",
|
||||
"pin_no_session": "Økten er utløpt - vennligst registrer deg på nytt.",
|
||||
"remove_pin_confirm_title": "Fjerne PIN-kode?",
|
||||
"remove_pin_confirm_desc": "Du må logge på igjen på denne enheten med Passkey eller gjenopprettingsnøkkel.",
|
||||
"remove_pin_confirm_yes": "Fjern PIN-kode",
|
||||
"remove_pin_confirm_no": "Avbryt",
|
||||
"security_title": "Sjekkliste for sikkerhet",
|
||||
"security_desc": "Oversikt over de viktigste beskyttelsesmekanismene for kontoen din.",
|
||||
"security_passkeys_ok": "Minst én Passkey registrert",
|
||||
"security_passkeys_missing": "Nei Passkey registrert",
|
||||
"security_prf_ok": "PRF-nøkkelavledning aktiv",
|
||||
"security_prf_missing": "PRF ikke satt opp",
|
||||
"security_pin_ok": "Lokal PIN-kode på denne enheten",
|
||||
"security_pin_missing": "Ingen lokal PIN-kode",
|
||||
"security_recovery_ok": "Oppsett av gjenopprettingsnøkkel",
|
||||
"security_recovery_hint": "De 12 ordene ble vist under registreringen. Oppbevar dem frakoblet og adskilt fra enheten. Du kan opprette en ny nøkkel nedenfor - den gamle blir da ugyldig.",
|
||||
"recovery_rotate_btn": "Opprett en ny gjenopprettingsnøkkel",
|
||||
"recovery_rotate_confirm_title": "Opprette en ny gjenopprettingsnøkkel?",
|
||||
"recovery_rotate_confirm_desc": "Den forrige 12-ordsnøkkelen blir ugyldig umiddelbart. Sørg for at du oppbevarer den nye nøkkelen trygt før du fortsetter.",
|
||||
"recovery_rotate_confirm_yes": "Opprett ny nøkkel",
|
||||
"recovery_rotate_confirm_no": "Avbryt",
|
||||
"recovery_rotate_new_warning": "VIKTIG: Skriv ned disse 12 ordene og oppbevar dem offline. Den forrige gjenopprettingsnøkkelen er nå ugyldig.",
|
||||
"recovery_rotate_failed": "Gjenopprettingsnøkkel kunne ikke opprettes.",
|
||||
"recovery_rotate_no_session": "Krypteringsøkten er utløpt - logg ut og logg inn igjen, og prøv deretter på nytt.",
|
||||
"device_title": "Denne enheten",
|
||||
"device_desc": "Lokal hurtigbuffer, synkroniseringsstatus og hurtigpålogging i denne nettleseren.",
|
||||
"device_sync_pending": "{{count}} ventende synkroniseringsoppføringer",
|
||||
"device_sync_ok": "Alle lokale endringer synkroniseres",
|
||||
"device_remembered": "Konto for hurtiginnlogging lagret på denne enheten",
|
||||
"device_not_remembered": "Kontoen er ikke i hurtiginnloggingslisten",
|
||||
"device_forget_btn": "Glemt konto på denne enheten",
|
||||
"device_forget_confirm_title": "Fjerne hurtiginnlogging?",
|
||||
"device_forget_confirm_desc": "Kontoen forsvinner fra hurtiginnloggingslisten på denne enheten. Økten og de lokale loggbøkene beholdes.",
|
||||
"device_forget_confirm_yes": "Fjern",
|
||||
"device_forget_confirm_no": "Avbryt",
|
||||
"passkey_label": "Navn på ny Passkey (valgfritt)",
|
||||
"passkey_label_placeholder": "z. f.eks. MacBook, iPhone",
|
||||
"passkey_rename_btn": "Lagre navn",
|
||||
"passkey_rename_success": "Passkey navn lagret.",
|
||||
"passkey_rename_failed": "Passkey-Navn kunne ikke lagres.",
|
||||
"passkey_unnamed": "Uten tittel Passkey",
|
||||
"stats_title": "Statistikk",
|
||||
"stats_subtitle": "Om alle loggbøkene dine på denne enheten",
|
||||
"stats_logbooks": "Loggbøker",
|
||||
"stats_account_since": "Konto siden",
|
||||
"stats_shared_logbooks": "Felles loggbøker",
|
||||
"appearance_title": "App og visualisering",
|
||||
"appearance_desc": "Designet og fargevalget gjelder for hele appen på denne enheten.",
|
||||
"theme_label": "Appens designstil",
|
||||
"theme_auto": "Automatisk (OS-deteksjon)",
|
||||
"theme_ocean": "Ocean (glassmorfisme)",
|
||||
"theme_material": "Materiale (Android)",
|
||||
"theme_cupertino": "Cupertino (iOS)",
|
||||
"color_scheme_label": "Lys eller mørk modus",
|
||||
"color_scheme_auto": "Automatisk (system)",
|
||||
"color_scheme_light": "Lys",
|
||||
"color_scheme_dark": "Mørk",
|
||||
"integrations_title": "Integrasjoner",
|
||||
"owm_key": "OpenWeatherMap API-nøkkel",
|
||||
"owm_help": "Valgfritt: egen OpenWeatherMap API-nøkkel. Hvis ingen oppføring er gjort, brukes serverside-nøkkelen fra operatørkonfigurasjonen.",
|
||||
"prefs_save": "Spar",
|
||||
"prefs_saving": "...vil bli reddet...",
|
||||
"prefs_saved": "Reddet",
|
||||
"tour_title": "App-tur",
|
||||
"tour_desc": "La deg veilede gjennom de viktigste områdene i appen på nytt.",
|
||||
"tour_restart": "Start turen på nytt",
|
||||
"push_title": "Push-varsler",
|
||||
"push_desc": "Som loggbokseier vil du bli varslet når inviterte Crew-medlemmer synkroniserer endringer. Ingen innhold overføres i ren tekst.",
|
||||
"push_enable": "Gi oss beskjed om endringer i crewet",
|
||||
"push_active": "Push-varsler er aktive på denne enheten.",
|
||||
"push_unsupported": "Push-varsler støttes ikke i denne nettleseren.",
|
||||
"push_denied_hint": "Varsler er blokkert. Tillat dem i innstillingene i nettleseren eller på enheten.",
|
||||
"push_ios_install_hint": "På iPhone/iPad: Legg til app på startskjermen (iOS 16.4+) for å bruke push.",
|
||||
"push_error": "Push-varsler kunne ikke aktiveres.",
|
||||
"sections": {
|
||||
"account": "Konto og innstillinger",
|
||||
"fleet": "Flåte og crew",
|
||||
"security": "Sikkerhet og enhet",
|
||||
"stats": "Statistikk",
|
||||
"danger": "Faresone"
|
||||
}
|
||||
},
|
||||
"vessel_pool": {
|
||||
"title": "Skipsflåte",
|
||||
"section_title": "Dine skip",
|
||||
"subtitle": "Hold alle skip for loggbøkene dine her. Velg aktivt skip per loggbok fra listen.",
|
||||
"loading": "Laster skipsflåte…",
|
||||
"add_vessel": "Legg til skip",
|
||||
"edit_vessel": "Rediger skip",
|
||||
"no_vessels": "Ingen skip i poolen ennå.",
|
||||
"delete_confirm": "Fjerne dette skipet fra flåten?",
|
||||
"max_vessels": "Maksimalt 20 skip i poolen."
|
||||
},
|
||||
"logbook_vessel": {
|
||||
"title": "Skip for denne loggboken",
|
||||
"subtitle": "Velg skip for denne loggboken. Reisedager bruker seil- og tankdata fra valgt skip.",
|
||||
"active_vessel": "Skip for denne loggboken",
|
||||
"no_vessels_in_pool": "Ingen skip i flåten – legg til i brukerprofilen først.",
|
||||
"no_vessel": "Ingen skip valgt",
|
||||
"unnamed": "Uten navn",
|
||||
"save": "Lagre skip",
|
||||
"saved": "Loggbok-skip lagret.",
|
||||
"selection_only_hint": "Du ser skipet eieren har valgt (delt loggbok).",
|
||||
"manage_in_profile": "Administrer skip i brukerprofilen"
|
||||
},
|
||||
"person_pool": {
|
||||
"title": "Stamm-Crew og skippere",
|
||||
"subtitle": "Hold personpoolen din her – skippere og crew for alle loggbøker. Velg aktivt crew per loggbok og reisedag fra poolen.",
|
||||
"loading": "Laster personpool…",
|
||||
"skippers_section": "Skippere",
|
||||
"crew_section": "Stamm-Crew",
|
||||
"add_skipper": "Legg til skipper",
|
||||
"add_crew": "Legg til Crew-medlem",
|
||||
"edit_skipper": "Rediger skipper",
|
||||
"no_skippers": "Ingen skipper i poolen ennå.",
|
||||
"no_crew": "Ingen Crew-medlemmer i poolen ennå.",
|
||||
"delete_confirm": "Fjerne denne personen fra poolen?"
|
||||
},
|
||||
"logbook_crew": {
|
||||
"title": "Crew for denne loggboken",
|
||||
"subtitle": "Velg skipper og crew for denne loggboken. Nye reisedager arver valget som standard.",
|
||||
"loading": "Laster crew…",
|
||||
"active_skipper": "Skipper for denne loggboken",
|
||||
"active_crew": "Crew for denne loggboken",
|
||||
"no_skippers_in_pool": "Ingen skipper i poolen – legg til i brukerprofilen først.",
|
||||
"no_crew_in_pool": "Ingen crew i poolen – legg til i brukerprofilen først.",
|
||||
"no_skipper": "Ingen skipper valgt",
|
||||
"unnamed": "Uten navn",
|
||||
"save": "Lagre crew",
|
||||
"saved": "Loggbok-Crew lagret.",
|
||||
"selection_only_hint": "Du ser crewet eieren har valgt (delt loggbok)."
|
||||
},
|
||||
"entry_crew": {
|
||||
"title": "Crew på denne reisedagen",
|
||||
"subtitle": "Kan avvike fra loggbokstandard. Følgende dager arver fra forrige dag.",
|
||||
"day_skipper": "Skipper denne dagen",
|
||||
"day_crew": "Crew denne dagen",
|
||||
"no_skipper": "Ingen skipper valgt",
|
||||
"no_crew": "Ingen crew valgt"
|
||||
},
|
||||
"crew": {
|
||||
"title": "Skipper- og Crew-profiler",
|
||||
"skipper_section": "Skipperprofil",
|
||||
"skipper_read_only_hint": "Skipperprofilen kan bare redigeres av eieren av loggboken.",
|
||||
"crew_section": "Crew-liste",
|
||||
"add_crew": "Legg til Crew-medlem",
|
||||
"edit_crew": "Rediger Crew-medlem",
|
||||
"no_crew": "Ingen Crew-medlemmer er lagt til ennå.",
|
||||
"max_crew": "Maksimalt antall på 12 Crew-medlemmer i poolen er nådd.",
|
||||
"name": "Navn",
|
||||
"address": "adresse",
|
||||
"birthdate": "Bursdag",
|
||||
"phone": "Telefonnummer",
|
||||
"nationality": "Nasjonalitet",
|
||||
"passport": "Pass-/ID-nummer",
|
||||
"bloodtype": "Blodgruppe",
|
||||
"allergies": "Allergier",
|
||||
"diseases": "Eksisterende tilstander/sykdommer",
|
||||
"save": "Lagre skipperdata",
|
||||
"save_member": "Lagre medlem",
|
||||
"saved": "Skipperprofilen er vellykket lagret!",
|
||||
"loading": "Crew-filene er lastet inn...",
|
||||
"delete_confirm": "Er du sikker på at du vil fjerne dette Crew-medlemmet?"
|
||||
},
|
||||
"deviation": {
|
||||
"title": "Tabell over kompassavvik",
|
||||
"subtitle": "Angi den magnetiske kompassavbøyningen (avbøyning) for kurser (MgK) fra 000° til 360° i trinn på 10°.",
|
||||
"heading": "MgK",
|
||||
"deviation": "Distraksjon",
|
||||
"save": "Lagre kalibreringsrutenettet",
|
||||
"saving": "...vil bli reddet...",
|
||||
"saved": "Kalibreringsrutenettet er vellykket lagret!",
|
||||
"loading": "Kalibreringstabellen er lastet inn..."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Innstillinger for loggbok",
|
||||
"subtitle": "Del, sikkerhetskopier og samarbeid for denne loggboken.",
|
||||
"select_logbook_hint": "Velg en loggbok for å redigere innstillingene.",
|
||||
"no_key": "Ingen OpenWeatherMap API-nøkkel tilgjengelig. Lagre din egen nøkkel i brukerprofilen, eller kontakt operatøren.",
|
||||
"weather_success": "Værdata vellykket hentet!",
|
||||
"weather_error": "Henting av værdata mislyktes. Kontroller API-nøkkelen og tilkoblingen.",
|
||||
"weather_date_mismatch": "Værdata kan bare hentes ut for i dag ({{today}}). Denne loggbokoppføringen er datert {{date}}.",
|
||||
"gps_error": "Vennligst skriv inn en posisjon eller finn GPS-koordinatene.",
|
||||
"share_title": "Del loggbok (skrivebeskyttet)",
|
||||
"share_desc": "Aktiver dette alternativet for å opprette en offentlig, skrivebeskyttet lenke. Alle som har denne lenken, kan se seilasene, båtprofilene og crewet ditt. Krypteringsnøklene overføres aldri til serveren (de forblir i hash-delen av URL-en).",
|
||||
"share_privacy_warning": "Anbefaling: Del denne lenken kun privat (f.eks. via e-post eller messenger), ikke på sosiale medier.",
|
||||
"share_enable": "Aktiver offentlig lenke",
|
||||
"share_copied": "Linken er kopiert!",
|
||||
"share_copy_btn": "Kopier lenke",
|
||||
"link_qr_hint": "Skann QR-koden med telefonen",
|
||||
"link_qr_alt": "QR-kode for lenken",
|
||||
"danger_zone_title": "Faresone",
|
||||
"danger_zone_desc": "Hvis du sletter kontoen din, slettes alle dine Passkeys, loggbøker, skipsdata, Crew-profiler, reiseoppføringer og E2E-nøkler ugjenkallelig. Denne handlingen kan ikke angres.",
|
||||
"delete_account_btn": "Slett konto ugjenkallelig",
|
||||
"delete_account_confirm_title": "Slett konto?",
|
||||
"delete_account_confirm_desc": "Er du helt sikker på at du vil slette kontoen din og alle tilknyttede loggbøker og E2E-krypterte data ugjenkallelig?",
|
||||
"delete_account_confirm_yes": "Ja, slett konto og alle data",
|
||||
"delete_account_confirm_no": "Avbryt",
|
||||
"delete_account_failed": "Kontoen kunne ikke slettes. Vennligst prøv igjen.",
|
||||
"delete_backup_hint": "Tips: Lag sikkerhetskopier av loggbøkene dine (.daagbok.json) i innstillingene for hver loggbok før du sletter dem.",
|
||||
"deleting_account": "Kontoen vil bli slettet...",
|
||||
"invite_push_prompt_title": "Aktivere push-varsler?",
|
||||
"invite_push_prompt_message": "Så snart inviterte Crew-medlemmer synkroniserer endringer, kan du bli informert via push. Ingen loggbokinnhold sendes i ren tekst.",
|
||||
"invite_push_prompt_ios_message": "Så snart Crew-medlemmene synkroniserer endringer, kan du bli informert via push. På iPhone/iPad: Legg til appen på startskjermen (iOS 16.4+), og aktiver deretter push i brukerprofilen.",
|
||||
"invite_push_prompt_enable": "Aktiver nå",
|
||||
"invite_push_prompt_later": "Senere",
|
||||
"invite_push_prompt_success": "Push-varsler er aktive på denne enheten.",
|
||||
"backup_title": "Sikkerhetskopiering og gjenoppretting",
|
||||
"backup_desc": "Fullstendig kryptert sikkerhetskopi av denne loggboken (oppføringer, bilder, GPS-spor, crew, skip). Beskyttet med sikkerhetskopieringspassord - for gjenoppretting til denne eller en ny konto.",
|
||||
"backup_export_title": "Opprett sikkerhetskopi",
|
||||
"backup_export_desc": "Laster ned alle lokale data som .daagbok.json. Hold filen og passordfrasen adskilt og sikker.",
|
||||
"backup_restore_title": "Gjenopprett sikkerhetskopi",
|
||||
"backup_restore_desc": "Gjenoppretter en sikkerhetskopi til din nåværende konto - selv etter at du har registrert en ny konto.",
|
||||
"backup_passphrase": "Passord for sikkerhetskopiering",
|
||||
"backup_passphrase_placeholder": "Minst 8 tegn",
|
||||
"backup_passphrase_confirm": "Bekreft passordfrasen",
|
||||
"backup_passphrase_short": "Passordfrasen for sikkerhetskopiering må være på minst 8 tegn.",
|
||||
"backup_passphrase_mismatch": "Passordfraser stemmer ikke overens.",
|
||||
"backup_wrong_passphrase": "Passordfrasen er feil eller sikkerhetskopien er ødelagt.",
|
||||
"backup_export_btn": "Last ned sikkerhetskopi",
|
||||
"backup_exporting": "Sikkerhetskopien er opprettet...",
|
||||
"backup_export_success": "Sikkerhetskopi opprettet ({{count}} reisedager).",
|
||||
"backup_file_label": "Sikkerhetskopifil (.daagbok.json)",
|
||||
"backup_preview_btn": "Sjekk innhold",
|
||||
"backup_previewing": "Sjekk...",
|
||||
"backup_restore_btn": "Gjenopprett",
|
||||
"backup_restoring": "Vil bli restaurert...",
|
||||
"backup_restore_success": "Loggbok \"{{title}}\" er gjenopprettet.",
|
||||
"backup_restore_cancelled": "Gjenoppretting avlyst.",
|
||||
"backup_invalid_json": "Filen er ikke en gyldig JSON-fil.",
|
||||
"backup_invalid_format": "Ukjent eller utdatert sikkerhetskopiformat.",
|
||||
"backup_not_owner": "Det er bare eieren av loggboken som kan opprette sikkerhetskopier.",
|
||||
"backup_not_authenticated": "Vennligst logg inn for å gjenopprette en sikkerhetskopi.",
|
||||
"backup_id_conflict": "Det finnes allerede en loggbok med denne ID-en.",
|
||||
"backup_overwrite_confirm": "Den eksisterende loggboken med samme ID erstattes. Fortsette?",
|
||||
"backup_new_id_confirm": "Importere sikkerhetskopien som en ny loggbok med ny ID?",
|
||||
"backup_stat_entries": "{{count}} Reisedager",
|
||||
"backup_stat_photos": "{{count}} Bilder",
|
||||
"backup_stat_crew": "{{count}} Crew-poster",
|
||||
"backup_stat_tracks": "{{count}} GPS-spor",
|
||||
"backup_exported_at": "Eksportert: {{date}}"
|
||||
},
|
||||
"disclaimer": {
|
||||
"title": "Viktige merknader",
|
||||
"intro": "Vennligst les følgende instruksjoner før du bruker Kapteins Daagbok.",
|
||||
"e2e_title": "Ende-til-ende-kryptering",
|
||||
"e2e_body": "Loggbokdataene dine er kryptert fra ende til ende. Bare du - eller personer med din nøkkel - kan lese innholdet. Kun krypterte data lagres på serveren.",
|
||||
"pwa_title": "Progressiv webapp (PWA)",
|
||||
"pwa_body": "Kapteins Daagbok kjører som en progressiv webapp i nettleseren din og kan installeres på enheten din - på samme måte som en native-app, men uten en appbutikk.",
|
||||
"storage_title": "Lokal lagring og synkronisering",
|
||||
"storage_body": "Dataene dine lagres lokalt på enheten din (IndexedDB). Endringer synkroniseres med serveren når en Internett-tilkobling er aktiv. Du kan fortsette å jobbe uten tilkobling, synkroniseringen skjer senere.",
|
||||
"free_title": "Gratis og reklamefri",
|
||||
"free_body": "Kapteins Daagbok er gratis og inneholder ingen reklame.",
|
||||
"liability_title": "Ansvarsfraskrivelse",
|
||||
"liability_body": "Bruk av appen skjer på eget ansvar. Vi fraskriver oss ethvert ansvar for skader som oppstår som følge av bruk av appen - inkludert feilaktige eller ufullstendige loggbokoppføringer, tap av data eller tekniske feil.",
|
||||
"warranty_title": "Ingen garanti",
|
||||
"warranty_body": "Det gis ingen garanti for tjenestens funksjon, korrekthet eller tilgjengelighet. Driften kan når som helst bli avbrutt, begrenset eller kansellert.",
|
||||
"copyright": "© 2026 KnorrLabs, Markus F.J. Busche",
|
||||
"accept": "Godta og fortsett",
|
||||
"close": "Lukk",
|
||||
"button_title": "Merknader og ansvarsfraskrivelse"
|
||||
},
|
||||
"feedback": {
|
||||
"button_title": "Send tilbakemelding",
|
||||
"title": "Tilbakemeldinger",
|
||||
"intro": "Del feil, ideer eller generelle tilbakemeldinger. Meldingen din vil bli sendt til prosjektteamet via en sikker varslingskanal.",
|
||||
"category_label": "Kategori",
|
||||
"category_general": "Generelt",
|
||||
"category_bug": "Rapporter feil",
|
||||
"category_feature": "Forespørsel om funksjonalitet",
|
||||
"category_translation": "Oversettelsesfeil",
|
||||
"contact_label": "E-post (valgfritt)",
|
||||
"contact_placeholder": "deine@email.beispiel",
|
||||
"message_label": "Melding",
|
||||
"message_placeholder": "Beskriv tilbakemeldingene dine...",
|
||||
"send": "Send",
|
||||
"sending": "Vil bli sendt...",
|
||||
"cancel": "Avbryt",
|
||||
"success": "Tusen takk skal du ha! Tilbakemeldingen din er sendt.",
|
||||
"error_send": "Tilbakemelding kunne ikke sendes. Vennligst prøv igjen senere.",
|
||||
"error_invalid_email": "Vennligst skriv inn en gyldig e-postadresse.",
|
||||
"error_not_configured": "Tilbakemelding er ikke tilgjengelig på denne serveren.",
|
||||
"error_rate_limited": "For mange tilbakemeldinger på kort tid. Vennligst vent noen minutter.",
|
||||
"error_spam": "Denne meldingen kunne ikke sendes. Vennligst omformuler den."
|
||||
},
|
||||
"demo": {
|
||||
"logbook_title": "Demologgbok Østersjøen",
|
||||
"badge": "Demo",
|
||||
"public_banner": "Skrivebeskyttet demovisning",
|
||||
"cta_register": "Opprett konto",
|
||||
"back_to_login": "Til registreringen"
|
||||
},
|
||||
"invitation": {
|
||||
"error_invalid_key": "Invitasjonslenken er kryptografisk ugyldig (feil nøkkel).",
|
||||
"error_missing_key": "Invitasjonslenken inneholder ikke en dekrypteringsnøkkel (#key=...). Vennligst bruk den fullstendige lenken fra eieren.",
|
||||
"error_expired": "Denne invitasjonen har utløpt (gyldig i 48 timer).",
|
||||
"error_invalid_token": "Invitasjonstokenet er ugyldig.",
|
||||
"error_load_failed": "Invitasjonsdetaljer kunne ikke lastes inn.",
|
||||
"error_incomplete_session": "Økten er ufullstendig - vennligst logg inn på nytt (bruker-ID mangler).",
|
||||
"error_accept_failed": "Tiltredelse mislyktes.",
|
||||
"error_login_failed": "Passkey Innlogging mislyktes.",
|
||||
"error_username_missing": "Brukernavnet ble ikke funnet - vennligst logg inn på nytt.",
|
||||
"error_register_failed": "Registrering mislyktes.",
|
||||
"loading_joining": "Bli med...",
|
||||
"loading_checking": "Invitasjonen vil bli sjekket...",
|
||||
"loading_unlocking": "Loggboken er låst opp og synkronisert...",
|
||||
"loading_retrieving_key": "Last ned krypteringsnøkkelen...",
|
||||
"error_title": "Feil i invitasjonen",
|
||||
"back_to_start": "Tilbake til start",
|
||||
"title": "Invitasjon til loggbok",
|
||||
"invited_by": "Invitasjon fra",
|
||||
"vessel_logbook": "Skip / Loggbok",
|
||||
"signed_in_preparing": "Registrert som {{username}}. Tilslutning er under forberedelse...",
|
||||
"join_again": "Bli med igjen",
|
||||
"login_or_register_hint": "Logg inn eller registrer en konto for å bli med i loggboken.",
|
||||
"or_sign_up": "ELLER REGISTRER DEG PÅ NYTT",
|
||||
"register_crew_account": "Opprett en ny crew-konto",
|
||||
"username_label": "Brukernavn",
|
||||
"create_passkey": "Opprett Passkey",
|
||||
"switch_language_en": "Engelsk",
|
||||
"switch_language_de": "Tysk"
|
||||
},
|
||||
"stats": {
|
||||
"title": "Statistikk",
|
||||
"subtitle": "Oversikt over ruter, forbruk og kjøretype",
|
||||
"scope_label": "Evalueringsområde",
|
||||
"scope_logbook": "Denne loggboken",
|
||||
"scope_account": "Alle loggbøker",
|
||||
"loading": "Statistikken er beregnet...",
|
||||
"no_data": "Ingen reisedager tilgjengelig ennå.",
|
||||
"total_distance": "Total avstand",
|
||||
"travel_days": "Reisedager",
|
||||
"sail_distance": "Under seil",
|
||||
"motor_distance": "Maskinreise",
|
||||
"motor_hours_total": "Totalt antall maskintimer",
|
||||
"daily_motor_hours": "Maskintimer per reisedøgn",
|
||||
"avg_motor_hours": "Ø maskintimer per reisedøgn",
|
||||
"unknown_propulsion": "Ukjent",
|
||||
"fuel_total": "Totalt drivstoff",
|
||||
"water_total": "Totalt vann",
|
||||
"daily_etmal": "Daglige tider",
|
||||
"daily_consumption": "Daglig forbruk",
|
||||
"route_overview": "Rute",
|
||||
"route_map_title": "Oversikt over ruten",
|
||||
"propulsion_title": "Seil vs. maskin",
|
||||
"propulsion_hint": "Fordelingen er basert på loggbokhendelser per reisedag, ikke på GPS-segmenter.",
|
||||
"avg_distance": "Ø per reisedag",
|
||||
"avg_fuel": "Ø Drivstoff",
|
||||
"avg_water": "Ø Vann",
|
||||
"fuel_per_nm": "Drivstoff per sm",
|
||||
"fuel_per_motor_hour": "Drivstoff per maskintime",
|
||||
"daily_fuel_per_motor_hour": "Drivstofforbruk per maskintime per kjøredag",
|
||||
"fuel_legend": "Drivstoff",
|
||||
"water_legend": "Vann",
|
||||
"unit_nm": "sm",
|
||||
"unit_h": "h",
|
||||
"unit_l": "L",
|
||||
"day_label": "Dag {{day}}",
|
||||
"account_logbooks": "Oversikt over loggbøker",
|
||||
"col_logbook": "Loggbok",
|
||||
"event_series_title": "Hendelsesforløp",
|
||||
"event_series_hint": "Kronologiske verdier fra hendelsesloggen.",
|
||||
"event_series_pressure": "Lufttrykk",
|
||||
"event_series_wind": "Vind",
|
||||
"event_series_motor": "Motor",
|
||||
"event_series_empty": "Ingen oppføringer ennå."
|
||||
},
|
||||
"tour": {
|
||||
"skip": "Hopp over turen",
|
||||
"back": "Tilbake",
|
||||
"next": "Videre",
|
||||
"finish": "Ferdig",
|
||||
"progress": "Trinn {{current}} fra {{total}}",
|
||||
"steps": {
|
||||
"welcome": {
|
||||
"title": "Velkommen om bord!",
|
||||
"body": "Vi har laget en demo-loggbok med tre dagers reise i Kielfjorden for deg. Du kan når som helst slette eksempeloppføringene hvis du vil starte din egen loggbok. Denne korte omvisningen viser deg de viktigste funksjonene."
|
||||
},
|
||||
"welcome_public": {
|
||||
"title": "Velkommen om bord!",
|
||||
"body": "Utforsk vår demologgbok med tre dagers reise i Kielfjorden – uten konto. Omvisningen viser loggbokoppføringer og valg av skip og crew for denne loggboken. Flåte og stamm-crew legger du inn senere i brukerprofilen."
|
||||
},
|
||||
"nav_logs": {
|
||||
"title": "Loggbokoppføringer",
|
||||
"body": "Her administrerer du reisedagene dine - avreise, destinasjon, vær, drivstoffnivå og GPS-spor."
|
||||
},
|
||||
"entry_list": {
|
||||
"title": "Dine reisedager",
|
||||
"body": "Hvert kort representerer en reisedag. Trykk på en oppføring for å vise eller redigere detaljer."
|
||||
},
|
||||
"entry_open": {
|
||||
"title": "Åpen reisedag",
|
||||
"body": "Slik ser en fullført loggbok ut - med hendelser, tanknivåer og mer."
|
||||
},
|
||||
"entry_track": {
|
||||
"title": "GPS-sporing",
|
||||
"body": "Last opp GPX-filer eller se allerede lagrede ruter på kartet - inkludert avstand og hastighet."
|
||||
},
|
||||
"nav_vessel": {
|
||||
"title": "Skip for loggbok",
|
||||
"body": "Velg skip fra flåten for denne loggboken. Administrer skip i brukerprofilen under Flåte og crew."
|
||||
},
|
||||
"profile_vessel_pool": {
|
||||
"title": "Skipsflåte",
|
||||
"body": "I brukerprofilen legger du inn alle skip – charter, eget båt osv. Velg deretter riktig skip per loggbok."
|
||||
},
|
||||
"profile_crew_pool": {
|
||||
"title": "Stamm-Crew og skippere",
|
||||
"body": "I brukerprofilen vedlikeholder du en personpool – flere skippere (f.eks. charter) og crew for alle loggbøker."
|
||||
},
|
||||
"nav_logbook_crew": {
|
||||
"title": "Crew per loggbok",
|
||||
"body": "Velg skipper og crew fra poolen for denne loggboken. Reisedager arver valget som standard."
|
||||
},
|
||||
"nav_stats": {
|
||||
"title": "Dashbord for statistikk",
|
||||
"body": "Her kan du se kjørelengder, drivstofforbruk, rutekart og kjøreandeler - automatisk beregnet ut fra loggbokoppføringene dine."
|
||||
},
|
||||
"nav_feedback": {
|
||||
"title": "Send tilbakemelding",
|
||||
"body": "Du kan bruke dette skjemaet til å sende feil, ideer eller generelle tilbakemeldinger direkte til prosjektteamet - også etter omvisningen, når som helst ved hjelp av ikonet øverst til høyre."
|
||||
},
|
||||
"nav_profile": {
|
||||
"title": "Din brukerprofil",
|
||||
"body": "Du får tilgang til din personlige profil via skipperknappen øverst - uavhengig av hvilken loggbok du bruker."
|
||||
},
|
||||
"profile_preferences": {
|
||||
"title": "Regnskap og presentasjon",
|
||||
"body": "Her kan du administrere kontoidentitet, tema og lys/mørk modus. Du kan når som helst starte appturen på nytt. Passkeys og sikkerhetsinnstillinger finner du lenger ned i profilen."
|
||||
},
|
||||
"finish": {
|
||||
"title": "Greit!",
|
||||
"body": "Du kommer rett til statistikkoversikten. Du kan når som helst starte turen på nytt i brukerprofilen din. Ha en riktig god tur!"
|
||||
}
|
||||
}
|
||||
},
|
||||
"seo": {
|
||||
"title": "Kapteins Daagbok - Gratis digital loggbok for fritidsbåter (uten reklame)",
|
||||
"description": "Gratis, annonsefri digital loggbok med ende-til-ende-kryptering og Passkey-pålogging. Dokumenter seilingsdager, GPS-spor, Crew- og skipsdata på en sikker måte - også offline som PWA.",
|
||||
"keywords": "Yachtloggbok, skipsloggbok, loggbok om bord, seiling, Passkey, E2E-kryptering, GPS-sporing, maritim loggbok, gratis, reklamefri, gratis, uten reklame",
|
||||
"ogImageAlt": "Kapteins Daagbok Logo"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,990 @@
|
||||
{
|
||||
"translation": {
|
||||
"app": {
|
||||
"name": "Kapteins Daagbok",
|
||||
"tagline": "Loggbok för privat yacht",
|
||||
"beta": "Beta",
|
||||
"beta_hint": "Betaversion - funktioner kan fortfarande ändras"
|
||||
},
|
||||
"languages": {
|
||||
"de": "Deutsch",
|
||||
"en": "English",
|
||||
"da": "Dansk",
|
||||
"sv": "Svenska",
|
||||
"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": {
|
||||
"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_stay": "Stanna kvar",
|
||||
"unsaved_changes_save_leave": "Spara och lämna",
|
||||
"unsaved_changes_discard": "Kasta",
|
||||
"unsaved_changes_leave": "Övergivande"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Instrumentpanel",
|
||||
"vessel": "Fartygsdata",
|
||||
"crew": "Crew",
|
||||
"deviation": "Distraktionsbord",
|
||||
"logs": "Loggboksanteckningar",
|
||||
"stats": "Statistik",
|
||||
"settings": "Inställningar"
|
||||
},
|
||||
"auth": {
|
||||
"welcome": "Välkommen till Kapteins Daagbok",
|
||||
"tagline": "Din säkra, E2Ekrypterade loggbok för sjöfarten.",
|
||||
"register": "Registrera dig med Passkey",
|
||||
"login": "Logga in med Passkey",
|
||||
"login_as": "Logga in som {{name}}",
|
||||
"quick_login": "Snabb inloggning",
|
||||
"forget_account": "Glömt konto på den här enheten",
|
||||
"not_user": "Inte {{name}}?",
|
||||
"recovery_title": "Din återställningsnyckel",
|
||||
"recovery_warning": "VIKTIGT: Skriv ner dessa 12 ord. Om du förlorar din Passkey och dessa ord kan dina data inte återställas.",
|
||||
"confirm_recovery": "Jag har skrivit ner orden",
|
||||
"status_logged_in": "Inloggad",
|
||||
"status_logged_out": "Avbruten",
|
||||
"copied": "Kopierat!",
|
||||
"copy_phrase": "Kopiera tangent",
|
||||
"enter_recovery": "Ange återställningsnyckel",
|
||||
"recovery_fallback_warning": "Din Passkey har autentiserats, men din enhet stöder inte maskinvarubaserad nyckelavledning. Ange din återställningsnyckel på 12 ord för att dekryptera din loggbok.",
|
||||
"recovery_placeholder": "Ange din återställningsnyckel som består av 12 ord åtskilda av mellanslag...",
|
||||
"back": "Tillbaka",
|
||||
"decrypting": "Dekryptering...",
|
||||
"decrypt_logbook": "Dekryptera loggbok",
|
||||
"error_incorrect_recovery": "Felaktig återställningsnyckel. Dekryptering misslyckades.",
|
||||
"error_decryption_failed": "Dekrypteringen misslyckades. Vänligen kontrollera din återställningsnyckel.",
|
||||
"or_register": "eller registrera dig",
|
||||
"explore_demo": "Utforska demoversionen utan konto",
|
||||
"username_placeholder": "Användarnamn / Skepparnamn",
|
||||
"processing": "Bearbetning...",
|
||||
"help": "Hjälp",
|
||||
"setup_pin_title": "Ange lokal PIN-kod (tillval)",
|
||||
"setup_pin_warning": "Eftersom din enhet inte stöder direkt härledning av Passkey-nycklar måste du annars ange din nyckel på 12 ord varje gång du loggar in på den här enheten. Konfigurera en lokal PIN-kod för att undvika detta.",
|
||||
"pin_placeholder": "E.G. 123456",
|
||||
"pin_label": "Lokal PIN-kod (4-8 siffror)",
|
||||
"save_pin": "Spara PIN-kod och fortsätt",
|
||||
"skip_pin": "Skip & använd återvinning",
|
||||
"enter_pin_title": "Dekryptera med PIN-kod",
|
||||
"enter_pin_warning": "Ange din lokala PIN-kod för att låsa upp dekrypteringsnyckeln på den här enheten.",
|
||||
"enter_pin_placeholder": "Ange din PIN-kod...",
|
||||
"decrypt_with_pin": "Dekryptera",
|
||||
"use_recovery_instead": "Använd återställningsnycklar istället",
|
||||
"error_incorrect_pin": "Felaktig PIN-kod. Dekryptering misslyckades.",
|
||||
"error_invalid_host": "Passkeys fungerar inte via 127.0.0.1. Öppna appen via localhost.",
|
||||
"use_localhost_link": "Byt till localhost",
|
||||
"error_passkey_cancelled": "Passkey-inloggning avbröts eller gick ut. Försök igen.",
|
||||
"error_invalid_rp_id": "Passkey-domänen matchar inte (RP ID). Använd http://localhost:5173 med RP_ID=localhost i .env för lokal utveckling.",
|
||||
"error_session_incomplete": "Inloggning ofullständig. Logga in med passkey igen."
|
||||
},
|
||||
"pwa": {
|
||||
"title": "Installera app",
|
||||
"generic_benefit": "Installera Kapteins Daagbok på din enhet för snabbare åtkomst, offline-användning och permanent datalagring.",
|
||||
"ios_instructions": "På iPad/iPhone: Lägg till appen på startskärmen så att dina loggboksdata förblir skyddade och appen startar som en inbyggd app.",
|
||||
"ios_step_share": "Tryck på aktiesymbolen i fältet Safari.",
|
||||
"ios_step_add": "Välj \"Gå till startskärmen\"",
|
||||
"install_now": "Installera nu",
|
||||
"installing": "Installation...",
|
||||
"later": "Senare",
|
||||
"never": "Visa inte mer",
|
||||
"platform_ios": "Installation via Safari.",
|
||||
"platform_android": "Installation via webbläsaren",
|
||||
"platform_desktop": "Installation som en skrivbordsapp",
|
||||
"settings_section": "Installation av app",
|
||||
"update_title": "Uppdatering tillgänglig",
|
||||
"update_desc": "En ny version av Kapteins Daagbok är klar. Uppdatera för att få de senaste ändringarna.",
|
||||
"update_now": "Uppdatering nu",
|
||||
"update_reloading": "Laddar...",
|
||||
"storage_persist_hint": "Webbläsaren kan radera offlinedata. Tillåt permanent lagring så att din loggbok förblir skyddad."
|
||||
},
|
||||
"sync": {
|
||||
"status_synced": "Synkroniserad",
|
||||
"status_syncing": "Synkronisera...",
|
||||
"status_offline": "Offline-cache",
|
||||
"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": {
|
||||
"title": "Masterdata för fartyg",
|
||||
"name": "Yacht namn",
|
||||
"type": "Typ av båt",
|
||||
"type_unset": "- inte specificerad -",
|
||||
"type_sailing": "Segelyacht",
|
||||
"type_motor": "Motorbåt",
|
||||
"length_m": "Längd (m)",
|
||||
"draft_m": "Djupgående (m)",
|
||||
"air_draft_m": "Höjd (m)",
|
||||
"invalid_metric": "Ogiltigt numeriskt värde - ange meter som ett decimaltal (t.ex. 12,5).",
|
||||
"port": "Hem hamn",
|
||||
"owner": "Ägare",
|
||||
"charter": "Charterbolag",
|
||||
"registration": "Registreringsnummer/registreringsskylt",
|
||||
"callsign": "Radioanropssignal",
|
||||
"atis": "ATIS nr.",
|
||||
"mmsi": "MMSI nr.",
|
||||
"save": "Spara fartygsdata",
|
||||
"saving": "Kommer att sparas...",
|
||||
"saved": "Fartygsdata har sparats framgångsrikt!",
|
||||
"loading": "Fartygsdata är inlästa...",
|
||||
"sails_list": "Segel (befintliga segel)",
|
||||
"sails_help": "Ange här de segel som finns tillgängliga på din båt (t.ex. storsegel, genua, fock).",
|
||||
"add_sail": "Lägg till segel",
|
||||
"sail_name_placeholder": "z. t.ex. storsegel",
|
||||
"no_sails": "Inga segel lagrade.",
|
||||
"photo_add": "Lägg till foto",
|
||||
"photo_change": "Ändra foto",
|
||||
"photo_delete": "Ta bort foto",
|
||||
"tanks_section": "Tankar (kapacitet)",
|
||||
"tanks_help": "Valfritt i liter - möjliggör slider i journalen för kända tankstorlekar.",
|
||||
"freshwater_capacity_l": "Dricksvatten (liter)",
|
||||
"fuel_capacity_l": "Bränsle (liter)",
|
||||
"greywater_capacity_l": "Gråvatten (liter)",
|
||||
"invalid_tank_liters": "Ogiltigt numeriskt värde - ange liter som ett tal (t.ex. 200)."
|
||||
},
|
||||
"logs": {
|
||||
"title": "Loggboksjournal",
|
||||
"new_entry": "Ny resdag",
|
||||
"travel_details": "Detaljer om resan",
|
||||
"add_event": "Lägg till ny loggbokspost",
|
||||
"add_event_btn": "Lägg till händelse",
|
||||
"edit_event": "Redigera händelse",
|
||||
"save_event_btn": "Spara ändring",
|
||||
"cancel_event_edit": "Avbryt",
|
||||
"delete_event": "Ta bort händelse",
|
||||
"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.",
|
||||
"date": "datum",
|
||||
"day_of_travel": "Resedag",
|
||||
"travel_day_number": "Resedag {{number}}",
|
||||
"departure": "Starthamn (resa från)",
|
||||
"destination": "Destinationsport (till)",
|
||||
"route": "Resa från/till",
|
||||
"freshwater": "Färskvatten (liter)",
|
||||
"fuel": "Treibstoff / Bränsle (liter)",
|
||||
"greywater": "Gråvatten (liter)",
|
||||
"greywater_level": "Fyllnadsnivå",
|
||||
"tank_slider_of_max": "{{current}} / {{max}} L",
|
||||
"tank_capacity_tooltip": "Om tankens kapacitet (liter) finns lagrad i fartygets data kan du ange fyllnadsnivåerna här med hjälp av skjutreglaget.",
|
||||
"morning": "Stå på morgonen",
|
||||
"refilled": "Påfylld",
|
||||
"evening": "Kvällsställ",
|
||||
"consumption": "Daglig konsumtion",
|
||||
"signatures": "Underskrifter / frisläppande",
|
||||
"sign_skipper": "Skepparens signatur",
|
||||
"sign_crew": "Crews signatur",
|
||||
"sign_hint": "Signera med finger, penna eller mus",
|
||||
"sign_clear": "Radera",
|
||||
"sign_export_image": "[Signatur]",
|
||||
"sign_with_passkey": "Frigör med Passkey",
|
||||
"sign_passkey_signing": "Passkey begärs...",
|
||||
"sign_passkey_signed": "Utgiven av {{username}}",
|
||||
"sign_passkey_export": "Passkey: {{username}} ({{date}})",
|
||||
"sign_attribution_export": "{{username}} ({{date}})",
|
||||
"sign_passkey_clear": "Ta bort Passkey release",
|
||||
"sign_mode_passkey": "Passkey",
|
||||
"sign_mode_classic": "Klassisk",
|
||||
"sign_passkey_failed": "Passkey Frigöring misslyckades",
|
||||
"sign_passkey_cancelled": "Passkey Frigörandet inställt",
|
||||
"sign_invalid": "Signaturen är ogiltig - innehållet har ändrats",
|
||||
"sign_badge_skipper": "Skeppare",
|
||||
"sign_badge_skipper_invalid": "Ogiltig",
|
||||
"sign_badge_skipper_title_valid": "Skepparen har släppt",
|
||||
"sign_badge_skipper_title_invalid": "Skippers signatur ogiltig - innehållet har ändrats",
|
||||
"sign_classic_or_passkey": "Valfritt: klassisk signatur eller Passkey release ovan",
|
||||
"sign_crew_passkey_hint": "Crew-medlemmar med skrivbehörighet kan frigöra via Passkey.",
|
||||
"sign_offline_hint": "Passkey-Godkännande kräver Internet - klassisk signatur möjlig offline",
|
||||
"sign_lock_notice": "Efter undertecknandet är det inte möjligt att göra ändringar i loggboksanteckningen (utom foton) utan att skepparen och crewen måste underteckna på nytt.",
|
||||
"sign_lock_active": "Denna post är signerad. Ändringar i loggboken (utom foton) tar automatiskt bort skepparens och crews signaturer.",
|
||||
"sign_lock_warning_title": "Bekräfta underskrift",
|
||||
"sign_lock_warning": "Efter undertecknandet är det inte längre möjligt att göra ändringar i loggboksanteckningen (utom foton) utan att skepparen och crewen måste underteckna på nytt.\n\nVill du fortsätta?",
|
||||
"sign_proceed": "Teckna",
|
||||
"sign_cancel": "Avbryt",
|
||||
"sign_cleared_re_sign_title": "Underskrifter borttagna",
|
||||
"sign_cleared_re_sign": "Loggboksanteckningen har ändrats. Skepparens och crews namnteckningar har tagits bort. Vänligen underteckna igen.",
|
||||
"no_entries": "Inga loggboksposter hittade för denna yacht. Skapa din första resedag!",
|
||||
"back_to_list": "Tillbaka till tidskriftslistan",
|
||||
"save": "Spara loggbokssida",
|
||||
"saving": "Kommer att sparas...",
|
||||
"saved": "Loggbokssidan har sparats framgångsrikt!",
|
||||
"loading": "Journalen laddas...",
|
||||
"view_mode_label": "Vy",
|
||||
"view_list": "Lista",
|
||||
"live_mode": "Live",
|
||||
"live_title": "Live-journal",
|
||||
"live_loading": "Live-journal laddas...",
|
||||
"live_retry": "Försök igen",
|
||||
"live_load_error": "Live-journal kunde inte laddas.",
|
||||
"live_action_error": "Posten kunde inte sparas.",
|
||||
"live_open_editor": "Fullständig editor",
|
||||
"live_actions_label": "Snabbåtgärder",
|
||||
"live_stream_label": "Händelselogg",
|
||||
"live_stream_title": "Journal",
|
||||
"live_no_events": "Inga poster ännu — tryck på en åtgärd.",
|
||||
"live_motor_start": "Motor Start",
|
||||
"live_motor_stop": "Motor Stopp",
|
||||
"live_cast_off": "Avgång",
|
||||
"live_moor": "Anlöp",
|
||||
"live_sails_btn": "Segel",
|
||||
"live_sails_pick": "Välj segel",
|
||||
"live_sails_pick_hint": "Tryck på flera segel (tryck igen för att avmarkera), logga sedan.",
|
||||
"live_sails_selected": "Valt: {{sails}}",
|
||||
"live_sails_confirm": "Logga",
|
||||
"live_sails_confirm_count": "Logga ({{count}})",
|
||||
"live_sails": "Segel: {{sails}}",
|
||||
"live_fix": "Fix",
|
||||
"live_fix_coords": "Fix {{lat}}, {{lng}}",
|
||||
"live_fix_manual_hint": "GPS ej tillgänglig. Ange latitud och longitud manuellt, eller försök igen med GPS-knappen.",
|
||||
"live_fix_gps_loading": "Hämtar GPS-position…",
|
||||
"live_fix_invalid": "Ange giltiga koordinater (latitud −90…90, longitud −180…180).",
|
||||
"live_fix_lat_placeholder": "Latitud (Lat)",
|
||||
"live_fix_lng_placeholder": "Longitud (Lng)",
|
||||
"live_photo_btn": "Foto (kamera)",
|
||||
"live_photo_capture_btn": "Ta foto",
|
||||
"live_photo_save_btn": "Spara",
|
||||
"live_photo_retake_btn": "Ta om",
|
||||
"live_photo_capture_failed": "Bildtagning misslyckades. Försök igen.",
|
||||
"live_photo_open_camera_btn": "Öppna kamera",
|
||||
"live_photo_native_hint": "Ta ett foto med enhetens kamera och spara det här efteråt.",
|
||||
"live_photo_camera_starting": "Startar kamera…",
|
||||
"live_photo_camera_denied": "Kameraåtkomst nekad eller ej tillgänglig.",
|
||||
"live_photo_camera_unavailable": "Kameran stöds inte i den här webbläsaren.",
|
||||
"live_photo_error": "Foto kunde inte sparas.",
|
||||
"live_photo_entry": "Foto: {{caption}}",
|
||||
"live_photo_entry_plain": "Foto taget",
|
||||
"live_undo_photo_hint": "Foto sparat",
|
||||
"live_comment_btn": "Kommentar",
|
||||
"live_comment_placeholder": "Ange text…",
|
||||
"live_comment_confirm": "Logga",
|
||||
"live_gps_error": "GPS-position kunde inte bestämmas.",
|
||||
"live_gps_start_hint": "Börja alltid dagsresan med en position.",
|
||||
"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_visibility_btn": "Sikt",
|
||||
"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_visibility_entry": "Sikt {{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_visibility_placeholder": "t.ex. 10 km",
|
||||
"live_course_placeholder": "t.ex. 245",
|
||||
"live_fuel_placeholder": "Påfyllda liter",
|
||||
"live_water_placeholder": "Påfyllda liter",
|
||||
"live_sog_btn": "SOG",
|
||||
"live_stw_btn": "STW",
|
||||
"live_sog_entry": "SOG {{speed}} kn",
|
||||
"live_stw_entry": "STW {{speed}} kn",
|
||||
"live_sog_placeholder": "t.ex. 5,2",
|
||||
"live_stw_placeholder": "t.ex. 4,8",
|
||||
"live_sog_hint": "Fart över grund (kn) — GPS-värde fylls i om tillgängligt.",
|
||||
"delete_entry": "Ta bort tagg",
|
||||
"delete_confirm": "Är du säker på att du vill radera den här resedagen permanent?",
|
||||
"carry_over_tanks_title": "Överföra data från föregående dag?",
|
||||
"carry_over_tanks_confirm": "Ta över starthamn, färskvatten, bränsle och gråvatten från startnivåerna från resans sista dag?\n\nStarthamn: {{departure}}\nFärskvatten: {{fw}} L\nBränsle: {{fuel}} L\nGråvatten: {{greywater}} L",
|
||||
"carry_over_tanks_yes": "Ta över",
|
||||
"carry_over_tanks_no": "Börja med 0",
|
||||
"event_title": "Kronologisk händelselogg",
|
||||
"no_events": "Inga händelser inlagda för denna resdag ännu.",
|
||||
"event_time": "Tid på dygnet",
|
||||
"event_mgk": "MgK-kurs",
|
||||
"event_rwk": "RwK-kurs",
|
||||
"event_course_section": "Kurs",
|
||||
"course_dial_hint": "Vrid ringen eller gå in i grader",
|
||||
"course_dial_step_label": "Stegstorlek",
|
||||
"course_step_fine": "1°",
|
||||
"course_step_medium": "5°",
|
||||
"course_step_coarse": "10°",
|
||||
"course_tab_mgk": "MgK",
|
||||
"course_tab_rwk": "rwK",
|
||||
"course_invalid": "Ogiltig kurs (0-360)",
|
||||
"course_placeholder_degrees": "z. B. 180",
|
||||
"course_placeholder_cardinal": "z. E.G. NW",
|
||||
"compass_n": "N",
|
||||
"compass_e": "O",
|
||||
"compass_s": "S",
|
||||
"compass_w": "W",
|
||||
"wind_mode_cardinal": "Kardinal",
|
||||
"wind_mode_degrees": "Som examen",
|
||||
"event_wind_direction": "Vindriktning",
|
||||
"event_wind_strength": "Vindstyrka",
|
||||
"event_sea_state": "Havets tillstånd",
|
||||
"event_visibility": "Sikt",
|
||||
"event_visibility_placeholder": "t.ex. 10 km",
|
||||
"weather_slider_unset": "—",
|
||||
"weather_slider_pressure": "{{value}} hPa",
|
||||
"weather_slider_sea_state": "Grad {{value}}",
|
||||
"weather_slider_heel": "{{value}}°",
|
||||
"event_weather": "Väder",
|
||||
"event_log": "Log (sm)",
|
||||
"event_gps": "GPS-position",
|
||||
"event_location": "Plats / hamn",
|
||||
"event_location_placeholder": "z. t.ex. Kiel",
|
||||
"event_remarks": "Anmärkningar / incidenter",
|
||||
"gps_btn": "Hämta GPS-koordinater",
|
||||
"weather_btn": "OpenWeatherMap Ring upp väder",
|
||||
"event_wind_pressure": "Lufttryck (hPa)",
|
||||
"event_heel": "Krängning (°)",
|
||||
"event_sails": "Segelhantering / motor",
|
||||
"motor_propulsion": "Maskinens resa",
|
||||
"sails_picker_show_more": "Visa alla segel",
|
||||
"sails_picker_show_less": "Visa mindre",
|
||||
"motor_hours": "Maskintimmar (totalt)",
|
||||
"fuel_per_motor_hour": "Förbrukning per maskintimme",
|
||||
"event_distance": "Avstånd (sm)",
|
||||
"export_csv": "Hämta CSV.",
|
||||
"share_csv": "Aktie",
|
||||
"export_pdf": "Hämta PDF.",
|
||||
"exporting_pdf": "PDF genereras...",
|
||||
"photos_title": "Fotobilagor (E2E-krypterade)",
|
||||
"photo_caption_label": "Fotobeskrivning/etikett (valfritt)",
|
||||
"photo_caption_placeholder": "t.ex. sätta segel nära hamninloppet",
|
||||
"photo_btn": "Ta foto / ladda upp",
|
||||
"photo_processing": "Håller på att bearbetas...",
|
||||
"no_photos": "Inga foton kopplade till denna resdag ännu.",
|
||||
"photo_delete_confirm": "Är du säker på att du vill radera det här fotot permanent?",
|
||||
"confirm_yes": "Ja",
|
||||
"confirm_no": "Nej",
|
||||
"track_upload_title": "GPS-spårning (fil)",
|
||||
"track_upload_points": "Poäng",
|
||||
"gps_tracking_btn_gpx": "Ladda ner spårfil",
|
||||
"gps_track_upload_help": "Dra en GPX-, KML- eller GeoJSON-fil hit eller klicka för att välja",
|
||||
"gps_track_upload_btn": "Ladda upp GPS-spår",
|
||||
"gps_track_delete": "Ta bort spårfil",
|
||||
"gps_track_delete_confirm": "Är du säker på att du vill radera den här spårfilen permanent?",
|
||||
"track_distance": "GPS-rutt (sm)",
|
||||
"track_speed_max": "Max. hastighet Hastighet (kn)",
|
||||
"track_speed_avg": "Ø Hastighet (kn)",
|
||||
"track_map_title": "GPS-spår på OpenSeaMap",
|
||||
"track_map_start": "Start",
|
||||
"track_map_end": "Mål",
|
||||
"track_map_speed_slow": "långsamt",
|
||||
"track_map_speed_fast": "snabb",
|
||||
"track_map_error": "Kartan kunde inte läsas in.",
|
||||
"exporting": "Export...",
|
||||
"share_unsupported": "Delning stöds inte på den här enheten. Filen har laddats ner istället.",
|
||||
"invite_crew": "Bjud in crewen",
|
||||
"invite_link_copied": "Länk till inbjudan kopierad till urklipp!",
|
||||
"invite_link_desc": "Dela den här länken med Crew-medlemmar för att ge dem skrivrättigheter till loggboken.",
|
||||
"collaborators_list": "Medlemmar / Crew",
|
||||
"revoke": "Ta bort",
|
||||
"revoke_confirm": "Är du säker på att du vill återkalla den här Crew-medlemmens åtkomst?",
|
||||
"invite_role": "Roll",
|
||||
"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": {
|
||||
"title": "Dina loggböcker",
|
||||
"subtitle": "Välj en loggbok eller skapa en ny för att hantera dina resor.",
|
||||
"create_btn": "Skapa loggbok",
|
||||
"new_logbook_placeholder": "Loggbokens eller båtens namn",
|
||||
"logout": "Logga ut",
|
||||
"logged_in_as": "Inloggad som {{name}}",
|
||||
"delete_confirm": "Är du säker på att du vill radera den här loggboken permanent? Alla lokala data och serverkopior kommer att förstöras.\n\nTips: Skapa en säkerhetskopia (.daagbok.json) i förväg under Inställningar → Säkerhetskopiering och återställning om du vill behålla data senare.",
|
||||
"no_logbooks": "Inga loggböcker hittades. Skapa din första loggbok för att komma igång!",
|
||||
"loading": "Loggböckerna är fulla...",
|
||||
"status_synced": "Synkroniserad",
|
||||
"status_local": "Endast lokal cache",
|
||||
"delete_btn": "Radera loggbok",
|
||||
"section_owned": "Mina loggböcker",
|
||||
"section_shared": "Delade loggböcker",
|
||||
"section_shared_hint": "Du har blivit inbjuden som Crew-medlem. Skepparens profil och inställningar tillhör ägaren.",
|
||||
"role_owner": "Egen loggbok",
|
||||
"role_owner_hint": "Du är ägare och skeppare till denna loggbok",
|
||||
"role_crew": "Tillträde för crewen",
|
||||
"role_crew_hint": "Inbjuden loggbok - du kan arbeta som crew och underteckna den",
|
||||
"role_read": "Endast läsning",
|
||||
"role_read_hint": "Delad loggbok - endast visning, ingen redigering",
|
||||
"open_profile": "Öppna profil för {{name}}",
|
||||
"open_logbook": "Öppna loggbok ”{{title}}”",
|
||||
"edit_title": "Byt namn på loggbok",
|
||||
"edit_placeholder": "Nytt namn på loggboken",
|
||||
"edit_success": "Loggboken har framgångsrikt bytt namn",
|
||||
"edit_btn": "Byt namn på",
|
||||
"filter_label": "Filtrera loggböcker",
|
||||
"filter_placeholder": "Namn, årtal, datum, crew eller fartyg …",
|
||||
"filter_clear": "Återställ filter",
|
||||
"filter_results": "{{count}} Träffar",
|
||||
"filter_no_results": "Inga loggböcker matchar din sökning. Försök med ett annat namn eller ett annat år.",
|
||||
"sort_label": "Sortera",
|
||||
"sort_by_label": "Sortera efter",
|
||||
"sort_by_name": "Namn",
|
||||
"sort_by_date": "datum",
|
||||
"sort_dir_label": "Sekvens",
|
||||
"sort_asc": "Stigande",
|
||||
"sort_desc": "Nedåtgående",
|
||||
"sort_name_asc": "Namn A till Ö",
|
||||
"sort_name_desc": "Namn Z till A",
|
||||
"sort_date_asc": "Äldst först",
|
||||
"sort_date_desc": "Nyast först"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Användarprofil",
|
||||
"subtitle": "Konto, Passkeys och statistik för {{name}}",
|
||||
"back": "Tillbaka till instrumentpanelen",
|
||||
"loading": "Profilen håller på att laddas...",
|
||||
"load_error": "Profilen kunde inte laddas.",
|
||||
"copy_failed": "Kopiering misslyckades.",
|
||||
"processing": "Håller på att bearbetas...",
|
||||
"identity_title": "Kontots identitet",
|
||||
"username": "Användarens namn",
|
||||
"user_id": "Användar-ID",
|
||||
"copy_user_id": "Kopiera användar-ID",
|
||||
"account_since": "Konto sedan",
|
||||
"prf_status": "Passkey härledning av nyckel (PRF)",
|
||||
"prf_active": "Aktiv",
|
||||
"prf_inactive": "Inte konfigurerad",
|
||||
"passkeys_title": "Passkeys",
|
||||
"passkeys_desc": "Registrera en separat Passkey på varje enhet. Detta gör att du kan logga in även efter att du bytt plattform.",
|
||||
"passkeys_empty": "Inga Passkeys hittades.",
|
||||
"add_passkey_btn": "Lägg till ny Passkey",
|
||||
"add_passkey_success": "Passkey har lagts till.",
|
||||
"add_passkey_failed": "Passkey kunde inte läggas till.",
|
||||
"remove_passkey_btn": "Ta bort Passkey.",
|
||||
"remove_passkey_last_title": "Senaste Passkey.",
|
||||
"remove_passkey_last_desc": "Den enda Passkey kan inte tas bort utan att du förlorar åtkomsten till ditt konto. Om du vill radera kontot helt använder du riskzonen längst ner på den här sidan.",
|
||||
"remove_passkey_failed": "Passkey kunde inte tas bort.",
|
||||
"remove_passkey_confirm_title": "Ta bort Passkey?",
|
||||
"remove_passkey_confirm_desc": "Denna enhet kan sedan inte längre logga in med denna Passkey.",
|
||||
"remove_passkey_confirm_yes": "Ta bort",
|
||||
"remove_passkey_confirm_no": "Avbryt",
|
||||
"pin_title": "Lokal PIN-kod",
|
||||
"pin_status": "Status",
|
||||
"pin_active": "Aktiv på den här enheten",
|
||||
"pin_inactive": "Inte konfigurerad",
|
||||
"pin_confirm_label": "Bekräfta PIN-kod",
|
||||
"pin_confirm_placeholder": "Ange PIN-koden igen",
|
||||
"pin_set_btn": "Ange PIN-kod",
|
||||
"pin_change_btn": "Ändra PIN-kod",
|
||||
"pin_remove_btn": "Ta bort PIN-koden",
|
||||
"pin_saved": "PIN-koden sparad.",
|
||||
"pin_save_failed": "PIN-koden kunde inte räddas.",
|
||||
"pin_mismatch": "PIN-koderna stämmer inte överens.",
|
||||
"pin_length_error": "PIN-koden måste innehålla minst 4 tecken.",
|
||||
"pin_no_session": "Sessionen har löpt ut - vänligen registrera dig igen.",
|
||||
"remove_pin_confirm_title": "Ta bort PIN-koden?",
|
||||
"remove_pin_confirm_desc": "Du måste logga in igen på den här enheten med Passkey eller återställningsnyckel.",
|
||||
"remove_pin_confirm_yes": "Ta bort PIN-koden",
|
||||
"remove_pin_confirm_no": "Avbryt",
|
||||
"security_title": "Checklista för säkerhet",
|
||||
"security_desc": "Översikt över de viktigaste skyddsmekanismerna för ditt konto.",
|
||||
"security_passkeys_ok": "Minst en Passkey registrerad",
|
||||
"security_passkeys_missing": "Nej Passkey registrerad",
|
||||
"security_prf_ok": "Avledning av PRF-nyckel aktiv",
|
||||
"security_prf_missing": "PRF inte upprättad",
|
||||
"security_pin_ok": "Lokal PIN-kod på den här enheten",
|
||||
"security_pin_missing": "Ingen lokal PIN-kod",
|
||||
"security_recovery_ok": "Uppsättning av återställningsnyckel",
|
||||
"security_recovery_hint": "De 12 orden visades under registreringen. Håll dem offline och åtskilda från enheten. Du kan skapa en ny nyckel nedan - den gamla kommer då att bli ogiltig.",
|
||||
"recovery_rotate_btn": "Skapa en ny återställningsnyckel",
|
||||
"recovery_rotate_confirm_title": "Skapa en ny återställningsnyckel?",
|
||||
"recovery_rotate_confirm_desc": "Den tidigare nyckeln på 12 ord blir ogiltig omedelbart. Se till att du förvarar den nya nyckeln säkert innan du fortsätter.",
|
||||
"recovery_rotate_confirm_yes": "Skapa ny nyckel",
|
||||
"recovery_rotate_confirm_no": "Avbryt",
|
||||
"recovery_rotate_new_warning": "VIKTIGT: Skriv ner dessa 12 ord och förvara dem offline. Den tidigare återställningsnyckeln är nu ogiltig.",
|
||||
"recovery_rotate_failed": "Återställningsnyckel kunde inte skapas.",
|
||||
"recovery_rotate_no_session": "Krypteringssessionen har löpt ut - logga ut och logga in igen och försök sedan igen.",
|
||||
"device_title": "Denna enhet",
|
||||
"device_desc": "Lokal cache, synkroniseringsstatus och snabb inloggning i den här webbläsaren.",
|
||||
"device_sync_pending": "{{count}} väntande synkroniseringsposter",
|
||||
"device_sync_ok": "Alla lokala ändringar synkroniseras",
|
||||
"device_remembered": "Konto för snabb inloggning sparat på den här enheten",
|
||||
"device_not_remembered": "Kontot finns inte med i listan för snabb inloggning",
|
||||
"device_forget_btn": "Glömt konto på den här enheten",
|
||||
"device_forget_confirm_title": "Ta bort snabb inloggning?",
|
||||
"device_forget_confirm_desc": "Kontot försvinner från snabbinloggningslistan på den här enheten. Din session och dina lokala loggböcker behålls.",
|
||||
"device_forget_confirm_yes": "Ta bort",
|
||||
"device_forget_confirm_no": "Avbryt",
|
||||
"passkey_label": "Namn för ny Passkey (valfritt)",
|
||||
"passkey_label_placeholder": "z. t.ex. MacBook, iPhone",
|
||||
"passkey_rename_btn": "Spara namn",
|
||||
"passkey_rename_success": "Passkey namn sparat.",
|
||||
"passkey_rename_failed": "Passkey-Namnet kunde inte sparas.",
|
||||
"passkey_unnamed": "Utan titel Passkey",
|
||||
"stats_title": "Statistik",
|
||||
"stats_subtitle": "Om alla dina loggböcker på den här enheten",
|
||||
"stats_logbooks": "Loggböcker",
|
||||
"stats_account_since": "Konto sedan",
|
||||
"stats_shared_logbooks": "Delade loggböcker",
|
||||
"appearance_title": "App & visualisering",
|
||||
"appearance_desc": "Designen och färgschemat gäller för hela appen på den här enheten.",
|
||||
"theme_label": "Appens designstil",
|
||||
"theme_auto": "Automatisk (OS-detektering)",
|
||||
"theme_ocean": "Ocean (glasmorfism)",
|
||||
"theme_material": "Material (Android)",
|
||||
"theme_cupertino": "Cupertino (iOS)",
|
||||
"color_scheme_label": "Ljust eller mörkt läge",
|
||||
"color_scheme_auto": "Automatisk (system)",
|
||||
"color_scheme_light": "Ljus",
|
||||
"color_scheme_dark": "Mörk",
|
||||
"integrations_title": "Integrationer",
|
||||
"owm_key": "OpenWeatherMap API-nyckel",
|
||||
"owm_help": "Valfritt: egen OpenWeatherMap API-nyckel. Om inget anges används nyckeln på serversidan från operatörskonfigurationen.",
|
||||
"prefs_save": "Spara",
|
||||
"prefs_saving": "Kommer att sparas...",
|
||||
"prefs_saved": "Sparade",
|
||||
"tour_title": "App-turné",
|
||||
"tour_desc": "Låt dig vägledas genom de viktigaste områdena i appen igen.",
|
||||
"tour_restart": "Starta resan igen",
|
||||
"push_title": "Push-meddelanden",
|
||||
"push_desc": "Som loggboksägare får du ett meddelande när inbjudna Crew-medlemmar synkroniserar ändringar. Inget innehåll överförs i klartext.",
|
||||
"push_enable": "Meddela oss om förändringar i crewen",
|
||||
"push_active": "Push-meddelanden är aktiva på den här enheten.",
|
||||
"push_unsupported": "Push-meddelanden stöds inte i den här webbläsaren.",
|
||||
"push_denied_hint": "Meddelanden är blockerade. Tillåt dem i webbläsarens eller enhetens inställningar.",
|
||||
"push_ios_install_hint": "På iPhone/iPad: Lägg till app på startskärmen (iOS 16.4+) för att använda push.",
|
||||
"push_error": "Push-meddelanden kunde inte aktiveras.",
|
||||
"sections": {
|
||||
"account": "Konto och inställningar",
|
||||
"fleet": "Flotta och besättning",
|
||||
"security": "Säkerhet och enhet",
|
||||
"stats": "Statistik",
|
||||
"danger": "Riskzon"
|
||||
}
|
||||
},
|
||||
"vessel_pool": {
|
||||
"title": "Skipsflotta",
|
||||
"section_title": "Dina fartyg",
|
||||
"subtitle": "Underhåll alla fartyg för dina loggböcker här. Välj aktivt fartyg per loggbok från listan.",
|
||||
"loading": "Laddar fartygsflotta…",
|
||||
"add_vessel": "Lägg till fartyg",
|
||||
"edit_vessel": "Redigera fartyg",
|
||||
"no_vessels": "Inga fartyg i poolen ännu.",
|
||||
"delete_confirm": "Ta bort detta fartyg från flottan?",
|
||||
"max_vessels": "Högst 20 fartyg i poolen."
|
||||
},
|
||||
"logbook_vessel": {
|
||||
"title": "Fartyg för denna loggbok",
|
||||
"subtitle": "Välj fartyg för denna loggbok. Resdagar använder segel- och tankdata från valt fartyg.",
|
||||
"active_vessel": "Fartyg för denna loggbok",
|
||||
"no_vessels_in_pool": "Inget fartyg i flottan – lägg till i användarprofilen först.",
|
||||
"no_vessel": "Inget fartyg valt",
|
||||
"unnamed": "Namnlös",
|
||||
"save": "Spara fartyg",
|
||||
"saved": "Loggbok-fartyg sparat.",
|
||||
"selection_only_hint": "Du ser fartyget ägaren valt (delad loggbok).",
|
||||
"manage_in_profile": "Hantera fartyg i användarprofilen"
|
||||
},
|
||||
"person_pool": {
|
||||
"title": "Stamm-Crew och skeppare",
|
||||
"subtitle": "Underhåll din personpool här – skeppare och crew för alla loggböcker. Välj aktiv crew per loggbok och resdag från poolen.",
|
||||
"loading": "Laddar personpool…",
|
||||
"skippers_section": "Skeppare",
|
||||
"crew_section": "Stamm-Crew",
|
||||
"add_skipper": "Lägg till skeppare",
|
||||
"add_crew": "Lägg till Crew-medlem",
|
||||
"edit_skipper": "Redigera skeppare",
|
||||
"no_skippers": "Ingen skeppare i poolen ännu.",
|
||||
"no_crew": "Inga Crew-medlemmar i poolen ännu.",
|
||||
"delete_confirm": "Ta bort denna person från poolen?"
|
||||
},
|
||||
"logbook_crew": {
|
||||
"title": "Crew för denna loggbok",
|
||||
"subtitle": "Välj skeppare och crew för denna loggbok. Nya resdagar ärver valet som standard.",
|
||||
"loading": "Laddar crew…",
|
||||
"active_skipper": "Skeppare för denna loggbok",
|
||||
"active_crew": "Crew för denna loggbok",
|
||||
"no_skippers_in_pool": "Ingen skeppare i poolen – lägg till i användarprofilen först.",
|
||||
"no_crew_in_pool": "Ingen crew i poolen – lägg till i användarprofilen först.",
|
||||
"no_skipper": "Ingen skeppare vald",
|
||||
"unnamed": "Namnlös",
|
||||
"save": "Spara crew",
|
||||
"saved": "Loggbok-Crew sparad.",
|
||||
"selection_only_hint": "Du ser den crew ägaren valt (delad loggbok)."
|
||||
},
|
||||
"entry_crew": {
|
||||
"title": "Crew denna resdag",
|
||||
"subtitle": "Kan skilja sig från loggboksstandard. Följande dagar ärver från föregående dag.",
|
||||
"day_skipper": "Skeppare denna dag",
|
||||
"day_crew": "Crew denna dag",
|
||||
"no_skipper": "Ingen skeppare vald",
|
||||
"no_crew": "Ingen crew vald"
|
||||
},
|
||||
"crew": {
|
||||
"title": "Profiler för skeppare och crew",
|
||||
"skipper_section": "Skepparens profil",
|
||||
"skipper_read_only_hint": "Skepparens profil kan endast redigeras av loggbokens ägare.",
|
||||
"crew_section": "Crew-lista",
|
||||
"add_crew": "Lägg till Crew-medlem",
|
||||
"edit_crew": "Redigera Crew-medlem",
|
||||
"no_crew": "Inga Crew-medlemmar har lagts till ännu.",
|
||||
"max_crew": "Maximalt antal på 12 Crew-medlemmar i poolen uppnått.",
|
||||
"name": "Namn",
|
||||
"address": "adress",
|
||||
"birthdate": "Födelsedag",
|
||||
"phone": "Telefonnummer",
|
||||
"nationality": "Nationalitet",
|
||||
"passport": "Pass/ID-nummer",
|
||||
"bloodtype": "Blodgrupp",
|
||||
"allergies": "Allergier",
|
||||
"diseases": "Redan existerande tillstånd/sjukdomar",
|
||||
"save": "Spara skeppardata",
|
||||
"save_member": "Spara medlem",
|
||||
"saved": "Skepparens profil har sparats!",
|
||||
"loading": "Crew-filerna är laddade...",
|
||||
"delete_confirm": "Är du säker på att du vill ta bort den här Crew-medlemmen?"
|
||||
},
|
||||
"deviation": {
|
||||
"title": "Tabell för kompassavvikelse",
|
||||
"subtitle": "Ange den magnetiska kompassdeflektionen (deflektion) för kurser (MgK) från 000° till 360° i steg om 10°.",
|
||||
"heading": "MgK",
|
||||
"deviation": "Distraktion",
|
||||
"save": "Spara kalibreringsrutan",
|
||||
"saving": "Kommer att sparas...",
|
||||
"saved": "Kalibreringsnätet har sparats framgångsrikt!",
|
||||
"loading": "Kalibreringsbordet är laddat..."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Inställningar för loggbok",
|
||||
"subtitle": "Dela, säkerhetskopiera och samarbeta för den här loggboken.",
|
||||
"select_logbook_hint": "Välj en loggbok för att redigera dess inställningar.",
|
||||
"no_key": "Ingen OpenWeatherMap API-nyckel tillgänglig. Spara din egen nyckel i användarprofilen eller kontakta operatören.",
|
||||
"weather_success": "Väderdata har hämtats framgångsrikt!",
|
||||
"weather_error": "Hämtning av väderdata misslyckades. Kontrollera API-nyckeln och anslutningen.",
|
||||
"weather_date_mismatch": "Väderdata kan endast hämtas för idag ({{today}}). Denna loggbokspost är daterad {{date}}.",
|
||||
"gps_error": "Ange en plats eller bestäm GPS-koordinaterna.",
|
||||
"share_title": "Aktieloggbok (skrivskyddad)",
|
||||
"share_desc": "Aktivera det här alternativet för att skapa en publik, skrivskyddad länk. Alla som har länken kan se dina resor, båtprofiler och crew. Krypteringsnycklarna överförs aldrig till servern (de finns kvar i hashdelen av URL:en).",
|
||||
"share_privacy_warning": "Rekommendation: Dela endast den här länken privat (t.ex. via e-post eller messenger), inte på sociala medier.",
|
||||
"share_enable": "Aktivera offentlig länk",
|
||||
"share_copied": "Länk kopierad!",
|
||||
"share_copy_btn": "Kopiera länk",
|
||||
"link_qr_hint": "Skanna QR-koden med mobilen",
|
||||
"link_qr_alt": "QR-kod för länken",
|
||||
"danger_zone_title": "Farlig zon",
|
||||
"danger_zone_desc": "Om du raderar ditt konto raderas oåterkalleligen alla dina Passkey, loggböcker, fartygsdata, Crew-profiler, reseanteckningar och E2E-nycklar. Denna åtgärd kan inte ångras.",
|
||||
"delete_account_btn": "Ta bort konto oåterkalleligt",
|
||||
"delete_account_confirm_title": "Radera konto?",
|
||||
"delete_account_confirm_desc": "Är du helt säker på att du oåterkalleligen vill radera ditt konto och alla tillhörande loggböcker och E2E-krypterade data?",
|
||||
"delete_account_confirm_yes": "Ja, radera konto och all data",
|
||||
"delete_account_confirm_no": "Avbryt",
|
||||
"delete_account_failed": "Kontot kunde inte raderas. Vänligen försök igen.",
|
||||
"delete_backup_hint": "Tips: Skapa säkerhetskopior av dina loggböcker (.daagbok.json) i inställningarna för varje loggbok innan du raderar dem.",
|
||||
"deleting_account": "Kontot kommer att raderas...",
|
||||
"invite_push_prompt_title": "Aktivera push-meddelanden?",
|
||||
"invite_push_prompt_message": "Så snart inbjudna Crew-medlemmar synkroniserar ändringar kan du bli informerad via push. Inget loggboksinnehåll skickas i klartext.",
|
||||
"invite_push_prompt_ios_message": "Så snart Crew-medlemmar synkroniserar ändringar kan du bli informerad via push. På iPhone/iPad: Lägg till appen på startskärmen (iOS 16.4+) och aktivera sedan push i användarprofilen.",
|
||||
"invite_push_prompt_enable": "Aktivera nu",
|
||||
"invite_push_prompt_later": "Senare",
|
||||
"invite_push_prompt_success": "Push-meddelanden är aktiva på den här enheten.",
|
||||
"backup_title": "Säkerhetskopiering och återställning",
|
||||
"backup_desc": "Komplett krypterad säkerhetskopia av denna loggbok (poster, foton, GPS-spår, crew, fartyg). Skyddad med lösenfras för säkerhetskopian - för återställning till detta eller ett nytt konto.",
|
||||
"backup_export_title": "Skapa säkerhetskopia",
|
||||
"backup_export_desc": "Laddar ner alla lokala data som .daagbok.json. Förvara filen och lösenfrasen separat och säkert.",
|
||||
"backup_restore_title": "Återställ säkerhetskopian",
|
||||
"backup_restore_desc": "Återställer en säkerhetskopia till ditt nuvarande konto - även efter att du har registrerat ett nytt konto.",
|
||||
"backup_passphrase": "Lösenord för säkerhetskopiering",
|
||||
"backup_passphrase_placeholder": "Minst 8 tecken",
|
||||
"backup_passphrase_confirm": "Bekräfta lösenfras",
|
||||
"backup_passphrase_short": "Säkerhetskopians lösenfras måste vara minst 8 tecken lång.",
|
||||
"backup_passphrase_mismatch": "Lösenfraserna stämmer inte överens.",
|
||||
"backup_wrong_passphrase": "Lösenordet är felaktigt eller säkerhetskopian är skadad.",
|
||||
"backup_export_btn": "Ladda ner backup",
|
||||
"backup_exporting": "Säkerhetskopian skapas...",
|
||||
"backup_export_success": "Säkerhetskopia skapad ({{count}} resdagar).",
|
||||
"backup_file_label": "Säkerhetskopieringsfil (.daagbok.json)",
|
||||
"backup_preview_btn": "Kontrollera innehåll",
|
||||
"backup_previewing": "Check...",
|
||||
"backup_restore_btn": "Återställ",
|
||||
"backup_restoring": "Kommer att återställas...",
|
||||
"backup_restore_success": "Loggbok \"{{title}}\" har återställts.",
|
||||
"backup_restore_cancelled": "Återhämtning avbruten.",
|
||||
"backup_invalid_json": "Filen är inte en giltig JSON-fil.",
|
||||
"backup_invalid_format": "Okänt eller föråldrat backupformat.",
|
||||
"backup_not_owner": "Endast loggbokens ägare kan skapa säkerhetskopior.",
|
||||
"backup_not_authenticated": "Logga in för att återställa en säkerhetskopia.",
|
||||
"backup_id_conflict": "En loggbok med detta ID finns redan.",
|
||||
"backup_overwrite_confirm": "Den befintliga loggboken med samma ID ersätts. Fortsätter du?",
|
||||
"backup_new_id_confirm": "Importera säkerhetskopian som en ny loggbok med ett nytt ID?",
|
||||
"backup_stat_entries": "{{count}} Resdagar",
|
||||
"backup_stat_photos": "{{count}} Foton",
|
||||
"backup_stat_crew": "{{count}} Crew-poster",
|
||||
"backup_stat_tracks": "{{count}} GPS-spår",
|
||||
"backup_exported_at": "Exporterad: {{date}}"
|
||||
},
|
||||
"disclaimer": {
|
||||
"title": "Viktiga anmärkningar",
|
||||
"intro": "Läs följande anvisningar innan du använder Kapteins Daagbok.",
|
||||
"e2e_title": "End-to-end-kryptering",
|
||||
"e2e_body": "Dina loggboksdata är krypterade från början till slut. Endast du - eller personer med din nyckel - kan läsa innehållet. Endast krypterade data lagras på servern.",
|
||||
"pwa_title": "Progressiv webbapplikation (PWA)",
|
||||
"pwa_body": "Kapteins Daagbok körs som en progressiv webbapp i din webbläsare och kan installeras på din enhet - på samma sätt som en native-app, utan en appbutik.",
|
||||
"storage_title": "Lokal lagring och synkronisering",
|
||||
"storage_body": "Dina data lagras lokalt på din enhet (IndexedDB). Ändringar synkroniseras med servern när en internetanslutning är aktiv. Du kan fortsätta att arbeta utan anslutning, synkroniseringen sker senare.",
|
||||
"free_title": "Kostnadsfritt och reklamfritt",
|
||||
"free_body": "Kapteins Daagbok är kostnadsfritt och innehåller ingen reklam.",
|
||||
"liability_title": "Ansvarsfriskrivning",
|
||||
"liability_body": "Användningen av appen sker på egen risk. Inget ansvar accepteras för skador som uppstår till följd av användningen av appen - inklusive felaktiga eller ofullständiga loggboksanteckningar, förlust av data eller tekniska fel.",
|
||||
"warranty_title": "Ingen garanti",
|
||||
"warranty_body": "Ingen garanti ges för tjänstens funktion, korrekthet eller tillgänglighet. Driften kan när som helst avbrytas, begränsas eller ställas in.",
|
||||
"copyright": "© 2026 KnorrLabs, Markus F.J. Busche",
|
||||
"accept": "Acceptera och fortsätt",
|
||||
"close": "Nära",
|
||||
"button_title": "Anmärkningar och ansvarsfriskrivning"
|
||||
},
|
||||
"feedback": {
|
||||
"button_title": "Skicka feedback",
|
||||
"title": "Återkoppling",
|
||||
"intro": "Dela med dig av buggar, idéer eller allmän feedback. Ditt meddelande kommer att skickas till projektgruppen via en säker meddelandekanal.",
|
||||
"category_label": "Kategori",
|
||||
"category_general": "Allmänt",
|
||||
"category_bug": "Rapportera fel",
|
||||
"category_feature": "Begäran om funktion",
|
||||
"category_translation": "Översättningsfel",
|
||||
"contact_label": "E-post (valfritt)",
|
||||
"contact_placeholder": "deine@email.beispiel",
|
||||
"message_label": "Meddelande",
|
||||
"message_placeholder": "Beskriv din feedback...",
|
||||
"send": "Skicka",
|
||||
"sending": "Kommer att skickas...",
|
||||
"cancel": "Avbryt",
|
||||
"success": "Tack så mycket! Din feedback har skickats.",
|
||||
"error_send": "Feedback kunde inte skickas. Vänligen försök igen senare.",
|
||||
"error_invalid_email": "Vänligen ange en giltig e-postadress.",
|
||||
"error_not_configured": "Feedback är inte tillgängligt på den här servern.",
|
||||
"error_rate_limited": "För många feedbackmeddelanden på kort tid. Vänligen vänta några minuter.",
|
||||
"error_spam": "Det här meddelandet kunde inte skickas. Vänligen omformulera det."
|
||||
},
|
||||
"demo": {
|
||||
"logbook_title": "Demo loggbok Östersjön",
|
||||
"badge": "Demo",
|
||||
"public_banner": "Skrivskyddad demovy",
|
||||
"cta_register": "Skapa konto",
|
||||
"back_to_login": "Till registreringen"
|
||||
},
|
||||
"invitation": {
|
||||
"error_invalid_key": "Länken till inbjudan är kryptografiskt ogiltig (nyckeln är felaktig).",
|
||||
"error_missing_key": "Länken till inbjudan innehåller ingen dekrypteringsnyckel (#key=...). Vänligen använd den fullständiga länken från ägaren.",
|
||||
"error_expired": "Denna inbjudan har löpt ut (giltig i 48 timmar).",
|
||||
"error_invalid_token": "Inbjudan ogiltig.",
|
||||
"error_load_failed": "Inbjudan kunde inte läsas in.",
|
||||
"error_incomplete_session": "Sessionen är ofullständig - logga in igen (användar-ID saknas).",
|
||||
"error_accept_failed": "Anslutningen misslyckades.",
|
||||
"error_login_failed": "Passkey Inloggningen misslyckades.",
|
||||
"error_username_missing": "Användarnamnet kunde inte fastställas - vänligen logga in igen.",
|
||||
"error_register_failed": "Registreringen misslyckades.",
|
||||
"loading_joining": "Ansluter sig...",
|
||||
"loading_checking": "Inbjudan kommer att kontrolleras...",
|
||||
"loading_unlocking": "Loggboken är upplåst och synkroniserad...",
|
||||
"loading_retrieving_key": "Ladda ner krypteringsnyckel...",
|
||||
"error_title": "Fel i inbjudan",
|
||||
"back_to_start": "Tillbaka till början",
|
||||
"title": "Inbjudan till loggbok",
|
||||
"invited_by": "Inbjudan från",
|
||||
"vessel_logbook": "Fartyg / Loggbok",
|
||||
"signed_in_preparing": "Registrerad som {{username}}. Anslutning förbereds...",
|
||||
"join_again": "Gå med igen",
|
||||
"login_or_register_hint": "Logga in eller registrera ett konto för att gå med i loggboken.",
|
||||
"or_sign_up": "ELLER REGISTRERA DIG IGEN",
|
||||
"register_crew_account": "Skapa ett nytt konto för crewen",
|
||||
"username_label": "Användarens namn",
|
||||
"create_passkey": "Skapa Passkey.",
|
||||
"switch_language_en": "Engelska",
|
||||
"switch_language_de": "Tysk"
|
||||
},
|
||||
"stats": {
|
||||
"title": "Statistik",
|
||||
"subtitle": "Översikt över rutter, förbrukning och typ av körning",
|
||||
"scope_label": "Utvärderingsområde",
|
||||
"scope_logbook": "Denna loggbok",
|
||||
"scope_account": "Alla loggböcker",
|
||||
"loading": "Statistiken är beräknad...",
|
||||
"no_data": "Inga resdagar tillgängliga ännu.",
|
||||
"total_distance": "Totalt avstånd",
|
||||
"travel_days": "Resdagar",
|
||||
"sail_distance": "Under segel",
|
||||
"motor_distance": "Maskinens resa",
|
||||
"motor_hours_total": "Totalt antal maskintimmar",
|
||||
"daily_motor_hours": "Maskintimmar per resdag",
|
||||
"avg_motor_hours": "Ø maskintimmar per resdag",
|
||||
"unknown_propulsion": "Okänd",
|
||||
"fuel_total": "Totalt bränsle",
|
||||
"water_total": "Totalt vatten",
|
||||
"daily_etmal": "Dagliga tider",
|
||||
"daily_consumption": "Daglig konsumtion",
|
||||
"route_overview": "Vägbeskrivning",
|
||||
"route_map_title": "Översikt över rutten",
|
||||
"propulsion_title": "Segel vs. maskin",
|
||||
"propulsion_hint": "Fördelningen baseras på loggbokshändelser per resdag, inte på GPS-segment.",
|
||||
"avg_distance": "Ø per resdag",
|
||||
"avg_fuel": "Ø Bränsle",
|
||||
"avg_water": "Ø Vatten",
|
||||
"fuel_per_nm": "Bränsle per sm",
|
||||
"fuel_per_motor_hour": "Bränsle per maskintimme",
|
||||
"daily_fuel_per_motor_hour": "Bränsleförbrukning per maskintimme och resdag",
|
||||
"fuel_legend": "Bränsle",
|
||||
"water_legend": "Vatten",
|
||||
"unit_nm": "sm",
|
||||
"unit_h": "h",
|
||||
"unit_l": "L",
|
||||
"day_label": "Dag {{day}}__.",
|
||||
"account_logbooks": "Loggböcker i en överblick",
|
||||
"col_logbook": "Loggbok",
|
||||
"event_series_title": "Händelseförlopp",
|
||||
"event_series_hint": "Kronologiska värden från händelseloggen.",
|
||||
"event_series_pressure": "Lufttryck",
|
||||
"event_series_wind": "Vind",
|
||||
"event_series_motor": "Motor",
|
||||
"event_series_empty": "Inga poster ännu."
|
||||
},
|
||||
"tour": {
|
||||
"skip": "Hoppa över turen",
|
||||
"back": "Tillbaka",
|
||||
"next": "Ytterligare",
|
||||
"finish": "Färdig",
|
||||
"progress": "Steg {{current}} från {{total}}.",
|
||||
"steps": {
|
||||
"welcome": {
|
||||
"title": "Välkommen ombord!",
|
||||
"body": "Vi har skapat en demo-loggbok med tre dagars resa i Kielfjorden åt dig. Du kan när som helst radera exempelposterna om du vill starta din egen loggbok. Den här korta rundturen visar dig de viktigaste funktionerna."
|
||||
},
|
||||
"welcome_public": {
|
||||
"title": "Välkommen ombord!",
|
||||
"body": "Utforska vår demologgbok med tre dagars resor i Kielfjorden – utan konto. Rundturen visar loggboksanteckningar samt val av fartyg och besättning för denna loggbok. Flotta och stamm-besättning hanterar du senare i användarprofilen."
|
||||
},
|
||||
"nav_logs": {
|
||||
"title": "Loggboksanteckningar",
|
||||
"body": "Det är här du hanterar dina resdagar - avresa, destination, väder, bränslenivåer och GPS-spår."
|
||||
},
|
||||
"entry_list": {
|
||||
"title": "Dina resdagar",
|
||||
"body": "Varje kort representerar en resdag. Tryck på en post för att visa eller redigera detaljer."
|
||||
},
|
||||
"entry_open": {
|
||||
"title": "Öppen resdag",
|
||||
"body": "Så här ser en komplett loggboksanteckning ut - med händelser, tanknivåer och mycket mer."
|
||||
},
|
||||
"entry_track": {
|
||||
"title": "GPS-spårning",
|
||||
"body": "Ladda upp GPX-filer eller visa redan sparade rutter på kartan - inklusive avstånd och hastighet."
|
||||
},
|
||||
"nav_vessel": {
|
||||
"title": "Fartyg för loggbok",
|
||||
"body": "Välj fartyg från flottan för denna loggbok. Hantera fartyg i användarprofilen under Flotta och besättning."
|
||||
},
|
||||
"profile_vessel_pool": {
|
||||
"title": "Fartygsflotta",
|
||||
"body": "I användarprofilen lägger du in alla fartyg – charter, egen båt m.m. Välj sedan rätt fartyg per loggbok."
|
||||
},
|
||||
"profile_crew_pool": {
|
||||
"title": "Stamm-Crew och skeppare",
|
||||
"body": "I användarprofilen underhåller du en personpool – flera skeppare (t.ex. charter) och crew för alla loggböcker."
|
||||
},
|
||||
"nav_logbook_crew": {
|
||||
"title": "Crew per loggbok",
|
||||
"body": "Välj skeppare och crew från poolen för denna loggbok. Resdagar ärver valet som standard."
|
||||
},
|
||||
"nav_stats": {
|
||||
"title": "Kontrollpanel för statistik",
|
||||
"body": "Här kan du se körsträckor, bränsleförbrukning, ruttkartor och körandelar - automatiskt beräknade från dina loggboksanteckningar."
|
||||
},
|
||||
"nav_feedback": {
|
||||
"title": "Skicka feedback",
|
||||
"body": "Du kan använda det här formuläret för att skicka fel, idéer eller allmän feedback direkt till projektgruppen - även efter rundturen när som helst med hjälp av ikonen längst upp till höger."
|
||||
},
|
||||
"nav_profile": {
|
||||
"title": "Din användarprofil",
|
||||
"body": "Du kommer åt din personliga profil via skipperknappen högst upp - oavsett vilken loggbok som är aktuell."
|
||||
},
|
||||
"profile_preferences": {
|
||||
"title": "Redovisning & presentation",
|
||||
"body": "Här kan du hantera din konto-identitet, ditt tema och ljus/mörker-läge. Du kan när som helst starta om appturen. Passkeys och säkerhetsinställningar hittar du längre ner i profilen."
|
||||
},
|
||||
"finish": {
|
||||
"title": "Okej!",
|
||||
"body": "Du kommer direkt till instrumentpanelen för statistik. Du kan när som helst starta om turen i din användarprofil. Ha en trevlig resa!"
|
||||
}
|
||||
}
|
||||
},
|
||||
"seo": {
|
||||
"title": "Kapteins Daagbok - Gratis digital loggbok för båtar (reklamfri)",
|
||||
"description": "Gratis, annonsfri digital loggbok för båtar med kryptering från början till slut och Passkey-inloggning. Dokumentera resdagar, GPS-spår, Crew- och fartygsdata på ett säkert sätt - även offline som PWA.",
|
||||
"keywords": "Yachtloggbok, skeppsdagbok, ombordloggbok, segling, Passkey, E2E kryptering, GPS-spår, sjöfartsloggbok, gratis, reklamfri, gratis, utan reklam",
|
||||
"ogImageAlt": "Kapteins Daagbok Logotyp"
|
||||
}
|
||||
}
|
||||
}
|
||||
+65
-95
@@ -1,64 +1,8 @@
|
||||
:root {
|
||||
--text: #6b6375;
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #aa3bff;
|
||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||
--accent-border: rgba(170, 59, 255, 0.5);
|
||||
--social-bg: rgba(244, 243, 236, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||
/* Minimal app shell — component styles live in App.css / themes.css */
|
||||
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, monospace;
|
||||
|
||||
font: 18px/145% var(--sans);
|
||||
letter-spacing: 0.18px;
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--text: #9ca3af;
|
||||
--text-h: #f3f4f6;
|
||||
--bg: #16171d;
|
||||
--border: #2e303a;
|
||||
--code-bg: #1f2028;
|
||||
--accent: #c084fc;
|
||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||
--accent-border: rgba(192, 132, 252, 0.5);
|
||||
--social-bg: rgba(47, 48, 58, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||
}
|
||||
|
||||
#social .button-icon {
|
||||
filter: invert(1) brightness(2);
|
||||
}
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 1126px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border-inline: 1px solid var(--border);
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -66,44 +10,70 @@ body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: var(--heading);
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
#root {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 56px;
|
||||
letter-spacing: -1.68px;
|
||||
margin: 32px 0;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 36px;
|
||||
margin: 20px 0;
|
||||
/* Scrollbars — auf Touch-Geräten breiter und besser sichtbar */
|
||||
:root {
|
||||
--app-scrollbar-size: 10px;
|
||||
}
|
||||
|
||||
@media (hover: none), (pointer: coarse), (max-width: 768px) {
|
||||
:root {
|
||||
--app-scrollbar-size: 14px;
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
line-height: 118%;
|
||||
letter-spacing: -0.24px;
|
||||
margin: 0 0 8px;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 20px;
|
||||
|
||||
html {
|
||||
scrollbar-width: auto;
|
||||
scrollbar-color: var(--app-accent-light) var(--app-surface-inset);
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
html::-webkit-scrollbar,
|
||||
body::-webkit-scrollbar,
|
||||
*::-webkit-scrollbar {
|
||||
width: var(--app-scrollbar-size);
|
||||
height: var(--app-scrollbar-size);
|
||||
}
|
||||
|
||||
html::-webkit-scrollbar-track,
|
||||
body::-webkit-scrollbar-track,
|
||||
*::-webkit-scrollbar-track {
|
||||
background: var(--app-surface-inset);
|
||||
border-radius: calc(var(--app-scrollbar-size) / 2);
|
||||
}
|
||||
|
||||
html::-webkit-scrollbar-thumb,
|
||||
body::-webkit-scrollbar-thumb,
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--app-accent-light) 55%, transparent);
|
||||
border-radius: calc(var(--app-scrollbar-size) / 2);
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
html::-webkit-scrollbar-thumb:hover,
|
||||
body::-webkit-scrollbar-thumb:hover,
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in srgb, var(--app-accent-light) 80%, transparent);
|
||||
}
|
||||
|
||||
@media (hover: none), (pointer: coarse), (max-width: 768px) {
|
||||
html::-webkit-scrollbar-thumb,
|
||||
body::-webkit-scrollbar-thumb,
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--app-accent-light) 70%, transparent);
|
||||
}
|
||||
|
||||
html::-webkit-scrollbar-thumb:active,
|
||||
body::-webkit-scrollbar-thumb:active,
|
||||
*::-webkit-scrollbar-thumb:active {
|
||||
background: var(--app-accent-light);
|
||||
}
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code,
|
||||
.counter {
|
||||
font-family: var(--mono);
|
||||
display: inline-flex;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 15px;
|
||||
line-height: 135%;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
+39
-3
@@ -3,14 +3,26 @@ import { createRoot } from 'react-dom/client'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import './themes.css'
|
||||
import './index.css'
|
||||
import './App.css'
|
||||
import './i18n'
|
||||
import App from './App.tsx'
|
||||
import { applyAppearanceToDocument } from './services/appearance.ts'
|
||||
import { flushPendingPwaBootEvents } from './services/analytics.ts'
|
||||
import {
|
||||
installStaleAssetRecovery,
|
||||
markReloadAttempt,
|
||||
reconcileServiceWorkerOnStartup
|
||||
reconcileVersionOnStartup
|
||||
} from './services/pwaStartup.ts'
|
||||
import { redirectToPasskeyCompatibleHostIfNeeded } from './utils/passkeyHost.ts'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__KDB_MAIN_MODULE_LOADED?: boolean
|
||||
__KDB_APP_BOOTSTRAPPED?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
window.__KDB_MAIN_MODULE_LOADED = true
|
||||
|
||||
/** Stale PWA precache on localhost can shadow Vite dev modules. */
|
||||
async function clearDevServiceWorkerCaches(): Promise<void> {
|
||||
@@ -39,16 +51,38 @@ function renderBootstrapError(message: string): void {
|
||||
}
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
if (redirectToPasskeyCompatibleHostIfNeeded()) {
|
||||
return
|
||||
}
|
||||
|
||||
applyAppearanceToDocument()
|
||||
installStaleAssetRecovery()
|
||||
flushPendingPwaBootEvents()
|
||||
window.addEventListener('load', () => {
|
||||
flushPendingPwaBootEvents()
|
||||
}, { once: true })
|
||||
await clearDevServiceWorkerCaches()
|
||||
|
||||
const shouldReloadForWaitingSw = await reconcileServiceWorkerOnStartup()
|
||||
if (shouldReloadForWaitingSw) {
|
||||
const startupResult = await reconcileVersionOnStartup()
|
||||
if (startupResult === 'reload') {
|
||||
markReloadAttempt()
|
||||
window.location.reload()
|
||||
return
|
||||
}
|
||||
if (startupResult === 'recovered') {
|
||||
return
|
||||
}
|
||||
|
||||
if ('serviceWorker' in navigator && !import.meta.env.DEV) {
|
||||
navigator.serviceWorker
|
||||
.register('/sw.js', { scope: '/' })
|
||||
.then((reg) => {
|
||||
console.log('Service Worker registered successfully with scope:', reg.scope)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Service Worker registration failed:', err)
|
||||
})
|
||||
}
|
||||
|
||||
const rootEl = document.getElementById('root')
|
||||
if (!rootEl) {
|
||||
@@ -60,6 +94,7 @@ async function bootstrap(): Promise<void> {
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
window.__KDB_APP_BOOTSTRAPPED = true
|
||||
}
|
||||
|
||||
void bootstrap().catch((err) => {
|
||||
@@ -67,4 +102,5 @@ void bootstrap().catch((err) => {
|
||||
renderBootstrapError(
|
||||
'Die App konnte nicht gestartet werden. Bitte neu laden oder die App vollständig beenden und erneut öffnen.',
|
||||
)
|
||||
window.__KDB_APP_BOOTSTRAPPED = false
|
||||
})
|
||||
|
||||
@@ -34,12 +34,33 @@ export const PlausibleEvents = {
|
||||
LOCAL_PIN_SET: 'Local PIN Set',
|
||||
LOCAL_PIN_REMOVED: 'Local PIN Removed',
|
||||
DEVICE_FORGOTTEN: 'Device Forgotten',
|
||||
RECOVERY_ROTATED: 'Recovery Rotated'
|
||||
RECOVERY_ROTATED: 'Recovery Rotated',
|
||||
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',
|
||||
PWA_BOOT_WATCHDOG_SOFT: 'PWA Boot Watchdog Soft',
|
||||
PWA_BOOT_WATCHDOG_HARD: 'PWA Boot Watchdog Hard',
|
||||
PWA_BOOT_WATCHDOG_FALLBACK: 'PWA Boot Watchdog Fallback',
|
||||
PWA_BOOT_WATCHDOG_MANUAL_REPAIR: 'PWA Boot Watchdog Manual Repair'
|
||||
} as const
|
||||
|
||||
/** Where a successful OpenWeatherMap API call originated (no coordinates or place names). */
|
||||
export type OwmAnalyticsSource = 'live_log' | 'entry_editor' | 'entry_editor_gps_lookup'
|
||||
|
||||
export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents]
|
||||
|
||||
export type PlausibleEventProps = Record<string, string | number | boolean>
|
||||
type PendingPwaBootEvent = {
|
||||
name: PlausibleEventName
|
||||
props?: PlausibleEventProps
|
||||
ts?: number
|
||||
}
|
||||
|
||||
const PWA_BOOT_PENDING_EVENTS_KEY = 'pwa_boot_pending_events'
|
||||
|
||||
export function trackPlausibleEvent(name: PlausibleEventName, props?: PlausibleEventProps): void {
|
||||
if (typeof window.plausible !== 'function') return
|
||||
@@ -49,3 +70,52 @@ export function trackPlausibleEvent(name: PlausibleEventName, props?: PlausibleE
|
||||
}
|
||||
window.plausible(name)
|
||||
}
|
||||
|
||||
export function flushPendingPwaBootEvents(): number {
|
||||
if (typeof window.plausible !== 'function') return 0
|
||||
|
||||
let raw: string | null = null
|
||||
try {
|
||||
raw = sessionStorage.getItem(PWA_BOOT_PENDING_EVENTS_KEY)
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
if (!raw) return 0
|
||||
|
||||
let pending: PendingPwaBootEvent[]
|
||||
try {
|
||||
pending = JSON.parse(raw) as PendingPwaBootEvent[]
|
||||
} catch {
|
||||
try {
|
||||
sessionStorage.removeItem(PWA_BOOT_PENDING_EVENTS_KEY)
|
||||
} catch {
|
||||
/* ignore storage errors */
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
if (!Array.isArray(pending) || pending.length === 0) {
|
||||
try {
|
||||
sessionStorage.removeItem(PWA_BOOT_PENDING_EVENTS_KEY)
|
||||
} catch {
|
||||
/* ignore storage errors */
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
for (const event of pending) {
|
||||
if (!event || typeof event.name !== 'string') continue
|
||||
if (event.props && Object.keys(event.props).length > 0) {
|
||||
window.plausible(event.name, { props: event.props })
|
||||
} else {
|
||||
window.plausible(event.name)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sessionStorage.removeItem(PWA_BOOT_PENDING_EVENTS_KEY)
|
||||
} catch {
|
||||
/* ignore storage errors */
|
||||
}
|
||||
return pending.length
|
||||
}
|
||||
|
||||
@@ -10,22 +10,43 @@ export class ApiError extends Error {
|
||||
|
||||
export async function apiFetch(
|
||||
input: string,
|
||||
init: RequestInit = {}
|
||||
init: RequestInit = {},
|
||||
timeoutMs = 15000
|
||||
): Promise<Response> {
|
||||
const headers = new Headers(init.headers)
|
||||
if (init.body !== undefined && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
|
||||
return fetch(input, {
|
||||
...init,
|
||||
headers,
|
||||
credentials: 'include'
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
|
||||
|
||||
if (init.signal) {
|
||||
if (init.signal.aborted) {
|
||||
controller.abort()
|
||||
} else {
|
||||
init.signal.addEventListener('abort', () => controller.abort())
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await fetch(input, {
|
||||
...init,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
signal: controller.signal
|
||||
})
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiJson<T>(input: string, init: RequestInit = {}): Promise<T> {
|
||||
const res = await apiFetch(input, init)
|
||||
export async function apiJson<T>(
|
||||
input: string,
|
||||
init: RequestInit = {},
|
||||
timeoutMs = 15000
|
||||
): Promise<T> {
|
||||
const res = await apiFetch(input, init, timeoutMs)
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
const message =
|
||||
|
||||
@@ -12,6 +12,7 @@ import { clearLogbookKeysCache } from './logbookKeys.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
|
||||
import { db } from './db.js'
|
||||
import { apiFetch, apiJson } from './api.js'
|
||||
import { isWebAuthnUserAbortError } from '../utils/passkeyHost.js'
|
||||
|
||||
const API_BASE = '/api/auth'
|
||||
|
||||
@@ -361,7 +362,11 @@ export async function loginUser(username?: string): Promise<LoginResult> {
|
||||
const prfRequested = !!options.extensions?.prf
|
||||
try {
|
||||
credentialResponse = await startAuthentication({ optionsJSON: options })
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
// User cancelled or timed out — never open a second platform prompt.
|
||||
if (isWebAuthnUserAbortError(err)) {
|
||||
throw err
|
||||
}
|
||||
if (prfRequested) {
|
||||
console.warn('Passkey authentication with PRF extension failed, retrying without PRF:', err)
|
||||
if (options.extensions) {
|
||||
@@ -553,7 +558,12 @@ export async function deleteAccount(): Promise<boolean> {
|
||||
db.photos.clear(),
|
||||
db.gpsTracks.clear(),
|
||||
db.syncQueue.clear(),
|
||||
db.logbookKeys.clear()
|
||||
db.logbookKeys.clear(),
|
||||
db.personPool.clear(),
|
||||
db.vesselPool.clear(),
|
||||
db.logbookCrewSelections.clear(),
|
||||
db.logbookVesselSelections.clear(),
|
||||
db.userSyncQueue.clear()
|
||||
])
|
||||
|
||||
// Wipe localStorage and session variables
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { decryptJson, encryptJson } from './crypto.js'
|
||||
import { getLogbookKey } from './logbookKeys.js'
|
||||
import type { PersonData } from '../types/person.js'
|
||||
import { buildLogbookCrewSelection, pickActiveSkipperId } from '../utils/personSnapshots.js'
|
||||
import { entryCrewFromLogbookSelection } from '../utils/personSnapshots.js'
|
||||
import { saveLogbookCrewSelection } from './logbookCrewSelection.js'
|
||||
const MIGRATION_FLAG = 'crew_pool_migration_v1_done'
|
||||
|
||||
export async function migrateLegacyCrewToPoolIfNeeded(): Promise<void> {
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId || localStorage.getItem(MIGRATION_FLAG) === userId) return
|
||||
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) return
|
||||
|
||||
try {
|
||||
const ownedLogbooks = await db.logbooks.filter((lb) => lb.isShared !== 1).toArray()
|
||||
const poolByLegacyKey = new Map<string, string>()
|
||||
const poolData = new Map<string, PersonData>()
|
||||
|
||||
for (const logbook of ownedLogbooks) {
|
||||
const logbookKey = (await getLogbookKey(logbook.id)) || masterKey
|
||||
const legacyCrews = await db.crews.where({ logbookId: logbook.id }).toArray()
|
||||
|
||||
const legacyIds: { skipperIds: string[]; crewIds: string[] } = {
|
||||
skipperIds: [],
|
||||
crewIds: []
|
||||
}
|
||||
|
||||
for (const record of legacyCrews) {
|
||||
const data = (await decryptJson(record.encryptedData, record.iv, record.tag, logbookKey)) as
|
||||
| PersonData
|
||||
| null
|
||||
if (!data) continue
|
||||
|
||||
const role = record.payloadId === 'skipper' ? 'skipper' : 'crew'
|
||||
const personData: PersonData = { ...data, role }
|
||||
const dedupeKey = `${role}:${personData.name}:${personData.passportNumber}`
|
||||
|
||||
let poolId = poolByLegacyKey.get(dedupeKey)
|
||||
if (!poolId) {
|
||||
poolId = record.payloadId === 'skipper' ? 'skipper' : record.payloadId
|
||||
const existing = await db.personPool.get(poolId)
|
||||
if (!existing) {
|
||||
const encrypted = await encryptJson(personData, masterKey)
|
||||
const now = new Date().toISOString()
|
||||
await db.personPool.put({
|
||||
payloadId: poolId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
await db.userSyncQueue.put({
|
||||
action: 'create',
|
||||
type: 'person',
|
||||
payloadId: poolId,
|
||||
data: JSON.stringify(encrypted),
|
||||
updatedAt: now
|
||||
})
|
||||
}
|
||||
poolByLegacyKey.set(dedupeKey, poolId)
|
||||
poolData.set(poolId, personData)
|
||||
}
|
||||
|
||||
if (role === 'skipper') {
|
||||
if (!legacyIds.skipperIds.includes(poolId)) legacyIds.skipperIds.push(poolId)
|
||||
} else {
|
||||
legacyIds.crewIds.push(poolId)
|
||||
}
|
||||
}
|
||||
|
||||
const activeSkipperId = pickActiveSkipperId(legacyIds.skipperIds)
|
||||
const existingSelection = await db.logbookCrewSelections.get(logbook.id)
|
||||
if (!existingSelection && (activeSkipperId || legacyIds.crewIds.length > 0)) {
|
||||
const selection = buildLogbookCrewSelection(
|
||||
activeSkipperId,
|
||||
legacyIds.crewIds,
|
||||
poolData
|
||||
)
|
||||
await saveLogbookCrewSelection(logbook.id, selection)
|
||||
|
||||
const entryCrew = entryCrewFromLogbookSelection(selection)
|
||||
const entries = await db.entries.where({ logbookId: logbook.id }).toArray()
|
||||
for (const entry of entries) {
|
||||
const dec = (await decryptJson(entry.encryptedData, entry.iv, entry.tag, logbookKey)) as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null
|
||||
if (!dec) continue
|
||||
if (dec.selectedSkipperId != null || (Array.isArray(dec.selectedCrewIds) && dec.selectedCrewIds.length > 0)) {
|
||||
continue
|
||||
}
|
||||
const updated = {
|
||||
...dec,
|
||||
...entryCrew
|
||||
}
|
||||
const encrypted = await encryptJson(updated, logbookKey)
|
||||
const now = new Date().toISOString()
|
||||
await db.entries.put({
|
||||
...entry,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
await db.syncQueue.put({
|
||||
action: 'update',
|
||||
type: 'entry',
|
||||
payloadId: entry.payloadId,
|
||||
logbookId: logbook.id,
|
||||
data: JSON.stringify(encrypted),
|
||||
updatedAt: now
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem(MIGRATION_FLAG, userId)
|
||||
} catch (err) {
|
||||
console.warn('Crew pool migration failed:', err)
|
||||
}
|
||||
}
|
||||
@@ -37,22 +37,17 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
throw new Error('Encryption key not found. User must log in.')
|
||||
}
|
||||
|
||||
// 1. Fetch Yacht details
|
||||
const yachtRecord = await db.yachts.get(logbookId);
|
||||
if (yachtRecord) {
|
||||
try {
|
||||
const yacht = await decryptJson(yachtRecord.encryptedData, yachtRecord.iv, yachtRecord.tag, masterKey);
|
||||
yachtName = yacht.name || '';
|
||||
homePort = yacht.port || '';
|
||||
owner = yacht.owner || '';
|
||||
charter = yacht.charter || '';
|
||||
registration = yacht.registration || '';
|
||||
callsign = yacht.callsign || '';
|
||||
atis = yacht.atis || '';
|
||||
mmsi = yacht.mmsi || '';
|
||||
} catch (e) {
|
||||
console.error('Failed to decrypt yacht details for CSV:', e);
|
||||
}
|
||||
const { resolveVesselForLogbook } = await import('./resolveVessel.js')
|
||||
const yacht = await resolveVesselForLogbook(logbookId)
|
||||
if (yacht) {
|
||||
yachtName = yacht.name || ''
|
||||
homePort = yacht.homePort || ''
|
||||
owner = yacht.owner || ''
|
||||
charter = yacht.charterCompany || ''
|
||||
registration = yacht.registrationNumber || ''
|
||||
callsign = yacht.callSign || ''
|
||||
atis = yacht.atis || ''
|
||||
mmsi = yacht.mmsi || ''
|
||||
}
|
||||
|
||||
// 2. Fetch logbook entries
|
||||
@@ -83,7 +78,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
'Skipper Signature', 'Crew Signature',
|
||||
'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)', 'Motor Hours (h)',
|
||||
'Event Time', 'MgK Course', 'RwK Course',
|
||||
'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State',
|
||||
'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State', 'Visibility',
|
||||
'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)',
|
||||
'Latitude', 'Longitude', 'Remarks',
|
||||
'Freshwater Morning (L)', 'Freshwater Refilled (L)', 'Freshwater Evening (L)', 'Freshwater Consumption (L)',
|
||||
@@ -134,7 +129,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
signS, signC,
|
||||
trackDist, trackMax, trackAvg, motorH,
|
||||
'', '', '',
|
||||
'', '', '', '',
|
||||
'', '', '', '', '',
|
||||
'', '', '', '', '',
|
||||
'', '', '',
|
||||
fwM, fwR, fwE, fwCons,
|
||||
@@ -152,6 +147,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
trackDist, trackMax, trackAvg, motorH,
|
||||
ev.time || '', ev.mgk || '', ev.rwk || '',
|
||||
ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '',
|
||||
ev.visibility || '',
|
||||
ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '',
|
||||
ev.gpsLat || '', ev.gpsLng || '', ev.remarks || '',
|
||||
fwM, fwR, fwE, fwCons,
|
||||
|
||||
+145
-1
@@ -35,6 +35,14 @@ export interface LocalDeviation {
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface EntryListCache {
|
||||
date: string
|
||||
dayOfTravel: string
|
||||
departure: string
|
||||
destination: string
|
||||
skipperSignStatus: 'none' | 'valid' | 'invalid'
|
||||
}
|
||||
|
||||
export interface LocalEntry {
|
||||
payloadId: string
|
||||
logbookId: string
|
||||
@@ -42,6 +50,8 @@ export interface LocalEntry {
|
||||
iv: string
|
||||
tag: string
|
||||
updatedAt: string
|
||||
/** Plaintext list fields — avoids full decrypt when opening the journal list. */
|
||||
listCache?: EntryListCache
|
||||
}
|
||||
|
||||
export interface LocalPhoto {
|
||||
@@ -64,6 +74,15 @@ export interface LocalGpsTrack {
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface LocalNmeaArchive {
|
||||
entryId: string
|
||||
logbookId: string
|
||||
encryptedData: string
|
||||
iv: string
|
||||
tag: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface LocalLogbookKey {
|
||||
logbookId: string
|
||||
encryptedKey: string
|
||||
@@ -71,16 +90,75 @@ export interface LocalLogbookKey {
|
||||
tag: string
|
||||
}
|
||||
|
||||
export interface LocalPerson {
|
||||
payloadId: string
|
||||
encryptedData: string
|
||||
iv: string
|
||||
tag: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface LocalVessel {
|
||||
payloadId: string
|
||||
encryptedData: string
|
||||
iv: string
|
||||
tag: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface LocalLogbookCrewSelection {
|
||||
logbookId: string
|
||||
encryptedData: string
|
||||
iv: string
|
||||
tag: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface LocalLogbookVesselSelection {
|
||||
logbookId: string
|
||||
encryptedData: string
|
||||
iv: string
|
||||
tag: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface SyncQueueItem {
|
||||
id?: number
|
||||
action: 'create' | 'update' | 'delete'
|
||||
type: 'yacht' | 'crew' | 'deviation' | 'entry' | 'logbook' | 'photo' | 'gpsTrack'
|
||||
type:
|
||||
| 'yacht'
|
||||
| 'crew'
|
||||
| 'deviation'
|
||||
| 'entry'
|
||||
| 'logbook'
|
||||
| 'photo'
|
||||
| 'gpsTrack'
|
||||
| 'logbookCrew'
|
||||
| 'logbookVessel'
|
||||
payloadId: string // payloadId or logbookId depending on the type
|
||||
logbookId: string
|
||||
data: string // JSON representation of the local record
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface UserSyncQueueItem {
|
||||
id?: number
|
||||
action: 'create' | 'update' | 'delete'
|
||||
type: 'person' | 'vessel'
|
||||
payloadId: string
|
||||
data: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface EntryDraftRecord {
|
||||
logbookId: string
|
||||
entryId: string
|
||||
encryptedData: string
|
||||
iv: string
|
||||
tag: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
class DaagboxDatabase extends Dexie {
|
||||
logbooks!: Table<LocalLogbook>
|
||||
yachts!: Table<LocalYacht>
|
||||
@@ -89,8 +167,15 @@ class DaagboxDatabase extends Dexie {
|
||||
entries!: Table<LocalEntry>
|
||||
photos!: Table<LocalPhoto>
|
||||
gpsTracks!: Table<LocalGpsTrack>
|
||||
nmeaArchives!: Table<LocalNmeaArchive>
|
||||
logbookKeys!: Table<LocalLogbookKey>
|
||||
personPool!: Table<LocalPerson>
|
||||
vesselPool!: Table<LocalVessel>
|
||||
logbookCrewSelections!: Table<LocalLogbookCrewSelection>
|
||||
logbookVesselSelections!: Table<LocalLogbookVesselSelection>
|
||||
syncQueue!: Table<SyncQueueItem>
|
||||
userSyncQueue!: Table<UserSyncQueueItem>
|
||||
entryDrafts!: Table<EntryDraftRecord, [string, string]>
|
||||
|
||||
constructor() {
|
||||
super('DaagboxDatabase')
|
||||
@@ -145,6 +230,65 @@ class DaagboxDatabase extends Dexie {
|
||||
gpsTracks: 'entryId, logbookId, updatedAt',
|
||||
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'
|
||||
})
|
||||
this.version(8).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',
|
||||
personPool: 'payloadId, updatedAt',
|
||||
logbookCrewSelections: 'logbookId, updatedAt',
|
||||
userSyncQueue: '++id, action, type, payloadId',
|
||||
entryDrafts: '[logbookId+entryId], updatedAt'
|
||||
})
|
||||
this.version(9).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',
|
||||
personPool: 'payloadId, updatedAt',
|
||||
vesselPool: 'payloadId, updatedAt',
|
||||
logbookCrewSelections: 'logbookId, updatedAt',
|
||||
logbookVesselSelections: 'logbookId, updatedAt',
|
||||
userSyncQueue: '++id, action, type, payloadId',
|
||||
entryDrafts: '[logbookId+entryId], updatedAt'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,13 @@ import { getActiveMasterKey } from './auth.js'
|
||||
import { getLogbookKey } from './logbookKeys.js'
|
||||
import { encryptJson } from './crypto.js'
|
||||
import { syncLogbook } from './sync.js'
|
||||
import { putEntryRecord } from '../utils/entryListCache.js'
|
||||
import { syncPersonPool } from './personPoolSync.js'
|
||||
import i18n from '../i18n/index.js'
|
||||
import type { PersonData } from '../types/person.js'
|
||||
import { buildLogbookCrewSelection } from '../utils/personSnapshots.js'
|
||||
import {
|
||||
buildDemoCrewRecords,
|
||||
buildDemoPersonPool,
|
||||
buildDemoEntryPayloads,
|
||||
buildDemoYachtData
|
||||
} from './demoLogbookData.js'
|
||||
@@ -24,7 +28,7 @@ export function getDemoFirstEntryStorageKey(userId: string): string {
|
||||
async function putEncryptedRecord(
|
||||
logbookId: string,
|
||||
key: ArrayBuffer,
|
||||
type: 'entry' | 'crew' | 'yacht' | 'gpsTrack',
|
||||
type: 'entry' | 'yacht' | 'gpsTrack' | 'logbookCrew',
|
||||
payloadId: string,
|
||||
data: unknown,
|
||||
now: string
|
||||
@@ -32,23 +36,17 @@ async function putEncryptedRecord(
|
||||
const encrypted = await encryptJson(data, key)
|
||||
|
||||
if (type === 'entry') {
|
||||
await db.entries.put({
|
||||
payloadId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
} else if (type === 'crew') {
|
||||
await db.crews.put({
|
||||
payloadId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
await putEntryRecord(
|
||||
{
|
||||
payloadId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
},
|
||||
data as Record<string, unknown>
|
||||
)
|
||||
} else if (type === 'yacht') {
|
||||
await db.yachts.put({
|
||||
logbookId,
|
||||
@@ -66,25 +64,62 @@ async function putEncryptedRecord(
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
} else if (type === 'logbookCrew') {
|
||||
await db.logbookCrewSelections.put({
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
}
|
||||
|
||||
await db.syncQueue.put({
|
||||
action: type === 'yacht' ? 'update' : 'create',
|
||||
action: type === 'yacht' || type === 'logbookCrew' ? 'update' : 'create',
|
||||
type,
|
||||
payloadId: type === 'yacht' ? logbookId : payloadId,
|
||||
payloadId: type === 'yacht' || type === 'logbookCrew' ? logbookId : payloadId,
|
||||
logbookId,
|
||||
data: JSON.stringify(encrypted),
|
||||
updatedAt: now
|
||||
})
|
||||
}
|
||||
|
||||
async function seedPersonPool(masterKey: ArrayBuffer, now: string): Promise<Map<string, PersonData>> {
|
||||
const poolMap = new Map<string, PersonData>()
|
||||
for (const person of buildDemoPersonPool()) {
|
||||
poolMap.set(person.payloadId, person.data)
|
||||
const encrypted = await encryptJson(person.data, masterKey)
|
||||
await db.personPool.put({
|
||||
payloadId: person.payloadId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
await db.userSyncQueue.put({
|
||||
action: 'create',
|
||||
type: 'person',
|
||||
payloadId: person.payloadId,
|
||||
data: JSON.stringify(encrypted),
|
||||
updatedAt: now
|
||||
})
|
||||
}
|
||||
syncPersonPool().catch((err) => console.warn('Demo person pool sync failed:', err))
|
||||
return poolMap
|
||||
}
|
||||
|
||||
async function seedYachtAndCrew(logbookId: string, key: ArrayBuffer, now: string): Promise<void> {
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not available')
|
||||
|
||||
const yachtData = buildDemoYachtData()
|
||||
await putEncryptedRecord(logbookId, key, 'yacht', logbookId, yachtData, now)
|
||||
|
||||
for (const crew of buildDemoCrewRecords()) {
|
||||
await putEncryptedRecord(logbookId, key, 'crew', crew.payloadId, crew.data, now)
|
||||
}
|
||||
const poolMap = await seedPersonPool(masterKey, now)
|
||||
const skipperId = [...poolMap.entries()].find(([, d]) => d.role === 'skipper')?.[0] ?? null
|
||||
const crewIds = [...poolMap.entries()].filter(([, d]) => d.role === 'crew').map(([id]) => id)
|
||||
const selection = buildLogbookCrewSelection(skipperId, crewIds, poolMap)
|
||||
await putEncryptedRecord(logbookId, key, 'logbookCrew', logbookId, selection, now)
|
||||
}
|
||||
|
||||
export interface DemoSeedResult {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { parseTrackFile } from './trackUpload.js'
|
||||
import { computeTrackStats } from '../utils/trackStats.js'
|
||||
import i18n from '../i18n/index.js'
|
||||
import { isGermanLocale } from '../utils/i18nLanguages.js'
|
||||
|
||||
import kielLaboeGpx from '../assets/demo/kiel-laboe.gpx?raw'
|
||||
import laboeDampGpx from '../assets/demo/laboe-damp.gpx?raw'
|
||||
@@ -15,6 +16,8 @@ const PUBLIC_DEMO_ENTRY_IDS = [
|
||||
'a0000001-0000-4000-8000-000000000003'
|
||||
] as const
|
||||
|
||||
export const PUBLIC_DEMO_SKIPPER_ID = 'skipper'
|
||||
export const PUBLIC_DEMO_VESSEL_ID = 'demo-vessel-1'
|
||||
const PUBLIC_DEMO_CREW_MEMBER_ID = 'a0000001-0000-4000-8000-000000000010'
|
||||
|
||||
export interface DemoDaySpec {
|
||||
@@ -48,10 +51,27 @@ export interface DemoCrewRecord {
|
||||
}
|
||||
}
|
||||
|
||||
export interface DemoVesselRecord {
|
||||
payloadId: string
|
||||
data: Record<string, unknown> & { name: string }
|
||||
}
|
||||
|
||||
export interface PublicDemoFixture {
|
||||
title: string
|
||||
yacht: Record<string, unknown>
|
||||
vesselPool: DemoVesselRecord[]
|
||||
logbookVesselSelection: {
|
||||
activeVesselId: string | null
|
||||
vesselSnapshot: Record<string, unknown> | null
|
||||
}
|
||||
/** @deprecated legacy share payload */
|
||||
crews: DemoCrewRecord[]
|
||||
personPool: DemoCrewRecord[]
|
||||
logbookCrewSelection: {
|
||||
activeSkipperId: string
|
||||
activeCrewIds: string[]
|
||||
snapshotsById: Record<string, DemoCrewRecord['data'] & { id: string }>
|
||||
}
|
||||
entries: Array<Record<string, unknown> & { payloadId: string }>
|
||||
gpsTracks: Array<{ entryId: string; waypoints: unknown[]; filename: string; gpxContent?: string; fileType: string }>
|
||||
photos: never[]
|
||||
@@ -59,7 +79,7 @@ export interface PublicDemoFixture {
|
||||
}
|
||||
|
||||
export function buildDemoDays(): DemoDaySpec[] {
|
||||
const isDe = i18n.language.startsWith('de')
|
||||
const isDe = isGermanLocale(i18n.language)
|
||||
return [
|
||||
{
|
||||
date: '2026-05-29',
|
||||
@@ -165,7 +185,7 @@ export function buildDemoDays(): DemoDaySpec[] {
|
||||
}
|
||||
|
||||
export function buildDemoYachtData(): Record<string, unknown> {
|
||||
const isDe = i18n.language.startsWith('de')
|
||||
const isDe = isGermanLocale(i18n.language)
|
||||
return {
|
||||
name: 'Seeadler',
|
||||
vesselType: isDe ? 'Segelyacht' : 'Sailing yacht',
|
||||
@@ -187,11 +207,15 @@ export function buildDemoYachtData(): Record<string, unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
export function buildDemoPersonPool(): DemoCrewRecord[] {
|
||||
return buildDemoCrewRecords()
|
||||
}
|
||||
|
||||
export function buildDemoCrewRecords(): DemoCrewRecord[] {
|
||||
const isDe = i18n.language.startsWith('de')
|
||||
const isDe = isGermanLocale(i18n.language)
|
||||
return [
|
||||
{
|
||||
payloadId: 'skipper',
|
||||
payloadId: PUBLIC_DEMO_SKIPPER_ID,
|
||||
data: {
|
||||
name: 'Demo Skipper',
|
||||
address: isDe ? 'Am Hafen 12, 24103 Kiel' : 'Harbour Quay 12, 24103 Kiel',
|
||||
@@ -225,10 +249,46 @@ export function buildDemoCrewRecords(): DemoCrewRecord[] {
|
||||
]
|
||||
}
|
||||
|
||||
function buildDemoVesselPool(yacht: Record<string, unknown>): DemoVesselRecord[] {
|
||||
return [
|
||||
{
|
||||
payloadId: PUBLIC_DEMO_VESSEL_ID,
|
||||
data: { name: String(yacht.name ?? 'Demo'), ...yacht }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function buildDemoLogbookVesselSelection(
|
||||
yacht: Record<string, unknown>
|
||||
): PublicDemoFixture['logbookVesselSelection'] {
|
||||
return {
|
||||
activeVesselId: PUBLIC_DEMO_VESSEL_ID,
|
||||
vesselSnapshot: { id: PUBLIC_DEMO_VESSEL_ID, ...yacht }
|
||||
}
|
||||
}
|
||||
|
||||
function buildDemoLogbookCrewSelection(pool: DemoCrewRecord[]) {
|
||||
const skipper = pool.find((p) => p.data.role === 'skipper')
|
||||
const crew = pool.filter((p) => p.data.role === 'crew')
|
||||
const snapshotsById: Record<string, DemoCrewRecord['data'] & { id: string }> = {}
|
||||
for (const p of pool) {
|
||||
snapshotsById[p.payloadId] = { id: p.payloadId, ...p.data }
|
||||
}
|
||||
return {
|
||||
activeSkipperId: skipper?.payloadId ?? PUBLIC_DEMO_SKIPPER_ID,
|
||||
activeCrewIds: crew.map((c) => c.payloadId),
|
||||
snapshotsById
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPublicDemoFixture(): PublicDemoFixture {
|
||||
const title = i18n.t('demo.logbook_title')
|
||||
const yacht = buildDemoYachtData()
|
||||
const crews = buildDemoCrewRecords()
|
||||
const vesselPool = buildDemoVesselPool(yacht)
|
||||
const logbookVesselSelection = buildDemoLogbookVesselSelection(yacht)
|
||||
const personPool = buildDemoPersonPool()
|
||||
const crews = personPool
|
||||
const logbookCrewSelection = buildDemoLogbookCrewSelection(personPool)
|
||||
const days = buildDemoDays()
|
||||
const entries: PublicDemoFixture['entries'] = []
|
||||
const gpsTracks: PublicDemoFixture['gpsTracks'] = []
|
||||
@@ -246,6 +306,9 @@ export function buildPublicDemoFixture(): PublicDemoFixture {
|
||||
destination: day.destination,
|
||||
freshwater: { ...day.freshwater },
|
||||
fuel: { ...day.fuel },
|
||||
selectedSkipperId: logbookCrewSelection.activeSkipperId,
|
||||
selectedCrewIds: [...logbookCrewSelection.activeCrewIds],
|
||||
crewSnapshotsById: { ...logbookCrewSelection.snapshotsById },
|
||||
signSkipper: '',
|
||||
signCrew: '',
|
||||
events: day.events
|
||||
@@ -278,7 +341,11 @@ export function buildPublicDemoFixture(): PublicDemoFixture {
|
||||
return {
|
||||
title,
|
||||
yacht,
|
||||
vesselPool,
|
||||
logbookVesselSelection,
|
||||
crews,
|
||||
personPool,
|
||||
logbookCrewSelection,
|
||||
entries,
|
||||
gpsTracks,
|
||||
photos: [],
|
||||
@@ -296,6 +363,7 @@ export function buildDemoEntryPayloads(): Array<{
|
||||
entryPayload: Record<string, unknown>
|
||||
trackData: { waypoints: unknown[]; gpxContent: string; filename: string; fileType: string }
|
||||
}> {
|
||||
const logbookCrewSelection = buildDemoLogbookCrewSelection(buildDemoPersonPool())
|
||||
const days = buildDemoDays()
|
||||
return days.map((day) => {
|
||||
const entryId = crypto.randomUUID()
|
||||
@@ -309,6 +377,9 @@ export function buildDemoEntryPayloads(): Array<{
|
||||
destination: day.destination,
|
||||
freshwater: { ...day.freshwater },
|
||||
fuel: { ...day.fuel },
|
||||
selectedSkipperId: logbookCrewSelection.activeSkipperId,
|
||||
selectedCrewIds: [...logbookCrewSelection.activeCrewIds],
|
||||
crewSnapshotsById: { ...logbookCrewSelection.snapshotsById },
|
||||
signSkipper: '',
|
||||
signCrew: '',
|
||||
events: day.events
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { apiFetch } from './api.js'
|
||||
|
||||
export type FeedbackCategory = 'bug' | 'feature' | 'general'
|
||||
export type FeedbackCategory = 'bug' | 'feature' | 'general' | 'translation'
|
||||
|
||||
export class FeedbackApiError extends Error {
|
||||
code: 'NOT_CONFIGURED' | 'REQUEST_FAILED' | 'INVALID_EMAIL' | 'RATE_LIMITED' | 'SPAM_DETECTED'
|
||||
|
||||
@@ -214,6 +214,10 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
|
||||
|
||||
if (response.ok) {
|
||||
const serverLb = await response.json()
|
||||
if (serverLb.id !== localId) {
|
||||
await saveLogbookKey(serverLb.id, logbookKey)
|
||||
await db.logbookKeys.delete(localId)
|
||||
}
|
||||
await db.logbooks.put({
|
||||
id: serverLb.id,
|
||||
encryptedTitle: serverLb.encryptedTitle,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { getLogbookKey } from './logbookKeys.js'
|
||||
import { decryptJson, encryptJson } from './crypto.js'
|
||||
import { syncLogbook } from './sync.js'
|
||||
import type { LogbookCrewSelectionData } from '../types/person.js'
|
||||
import { emptyLogbookCrewSelection } from '../types/person.js'
|
||||
import { buildLogbookCrewSelection } from '../utils/personSnapshots.js'
|
||||
import type { PersonData } from '../types/person.js'
|
||||
import { loadPersonPoolMap } from './personPool.js'
|
||||
|
||||
async function resolveLogbookKey(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 loadLogbookCrewSelection(
|
||||
logbookId: string
|
||||
): Promise<LogbookCrewSelectionData> {
|
||||
const record = await db.logbookCrewSelections.get(logbookId)
|
||||
if (!record) return emptyLogbookCrewSelection()
|
||||
|
||||
const key = await resolveLogbookKey(logbookId)
|
||||
const data = (await decryptJson(record.encryptedData, record.iv, record.tag, key)) as
|
||||
| LogbookCrewSelectionData
|
||||
| null
|
||||
if (!data) return emptyLogbookCrewSelection()
|
||||
|
||||
return {
|
||||
activeSkipperId: data.activeSkipperId ?? null,
|
||||
activeCrewIds: Array.isArray(data.activeCrewIds) ? data.activeCrewIds : [],
|
||||
snapshotsById: data.snapshotsById && typeof data.snapshotsById === 'object' ? data.snapshotsById : {}
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveLogbookCrewSelection(
|
||||
logbookId: string,
|
||||
selection: LogbookCrewSelectionData
|
||||
): Promise<void> {
|
||||
const key = await resolveLogbookKey(logbookId)
|
||||
const encrypted = await encryptJson(selection, key)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
await db.logbookCrewSelections.put({
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
await db.syncQueue.put({
|
||||
action: 'update',
|
||||
type: 'logbookCrew',
|
||||
payloadId: logbookId,
|
||||
logbookId,
|
||||
data: JSON.stringify(encrypted),
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
}
|
||||
|
||||
export async function saveLogbookCrewSelectionFromIds(
|
||||
logbookId: string,
|
||||
activeSkipperId: string | null,
|
||||
activeCrewIds: string[],
|
||||
poolOverride?: Map<string, PersonData>
|
||||
): Promise<LogbookCrewSelectionData> {
|
||||
const pool = poolOverride ?? (await loadPersonPoolMap())
|
||||
const selection = buildLogbookCrewSelection(activeSkipperId, activeCrewIds, pool)
|
||||
await saveLogbookCrewSelection(logbookId, selection)
|
||||
return selection
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { decryptJson } from './crypto.js'
|
||||
import { getLogbookKey } from './logbookKeys.js'
|
||||
import type { PersonData } from '../types/person.js'
|
||||
import { loadLogbookCrewSelection } from './logbookCrewSelection.js'
|
||||
import { loadPersonPoolMap } from './personPool.js'
|
||||
import { resolveVesselForLogbook } from './resolveVessel.js'
|
||||
import type { LogbookSearchFields } from '../utils/logbookFilter.js'
|
||||
|
||||
async function loadLegacyCrewNames(logbookId: string): Promise<string[]> {
|
||||
const records = await db.crews.where({ logbookId }).toArray()
|
||||
if (records.length === 0) return []
|
||||
|
||||
const key = (await getLogbookKey(logbookId)) || getActiveMasterKey()
|
||||
if (!key) return []
|
||||
|
||||
const names: string[] = []
|
||||
for (const record of records) {
|
||||
const data = (await decryptJson(record.encryptedData, record.iv, record.tag, key)) as PersonData | null
|
||||
const name = data?.name?.trim()
|
||||
if (name) names.push(name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
function collectCrewNamesFromSelection(
|
||||
selection: Awaited<ReturnType<typeof loadLogbookCrewSelection>>,
|
||||
pool: Map<string, PersonData>
|
||||
): string[] {
|
||||
const names = new Set<string>()
|
||||
|
||||
for (const snapshot of Object.values(selection.snapshotsById)) {
|
||||
const name = snapshot.name?.trim()
|
||||
if (name) names.add(name)
|
||||
}
|
||||
|
||||
const ids = [
|
||||
...(selection.activeSkipperId ? [selection.activeSkipperId] : []),
|
||||
...selection.activeCrewIds
|
||||
]
|
||||
for (const id of ids) {
|
||||
const fromSnapshot = selection.snapshotsById[id]?.name?.trim()
|
||||
if (fromSnapshot) {
|
||||
names.add(fromSnapshot)
|
||||
continue
|
||||
}
|
||||
const fromPool = pool.get(id)?.name?.trim()
|
||||
if (fromPool) names.add(fromPool)
|
||||
}
|
||||
|
||||
return [...names]
|
||||
}
|
||||
|
||||
export async function loadLogbookSearchFields(logbookId: string): Promise<LogbookSearchFields> {
|
||||
const [vessel, crewSelection, pool] = await Promise.all([
|
||||
resolveVesselForLogbook(logbookId),
|
||||
loadLogbookCrewSelection(logbookId),
|
||||
loadPersonPoolMap()
|
||||
])
|
||||
|
||||
let crewNames = collectCrewNamesFromSelection(crewSelection, pool)
|
||||
if (crewNames.length === 0) {
|
||||
crewNames = await loadLegacyCrewNames(logbookId)
|
||||
}
|
||||
|
||||
return {
|
||||
vesselName: vessel?.name?.trim() ?? '',
|
||||
crewNames
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadLogbookSearchFieldsBatch(
|
||||
logbookIds: string[]
|
||||
): Promise<Map<string, LogbookSearchFields>> {
|
||||
const uniqueIds = [...new Set(logbookIds)]
|
||||
const entries = await Promise.all(
|
||||
uniqueIds.map(async (id) => [id, await loadLogbookSearchFields(id)] as const)
|
||||
)
|
||||
return new Map(entries)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { getLogbookKey } from './logbookKeys.js'
|
||||
import { decryptJson, encryptJson } from './crypto.js'
|
||||
import { syncLogbook } from './sync.js'
|
||||
import type { LogbookVesselSelectionData } from '../types/vessel.js'
|
||||
import { emptyLogbookVesselSelection } from '../types/vessel.js'
|
||||
import { buildLogbookVesselSelection } from '../utils/vesselSnapshot.js'
|
||||
import type { VesselData } from '../types/vessel.js'
|
||||
import { loadVesselPoolMap } from './vesselPool.js'
|
||||
|
||||
async function resolveLogbookKey(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 loadLogbookVesselSelection(
|
||||
logbookId: string
|
||||
): Promise<LogbookVesselSelectionData> {
|
||||
const record = await db.logbookVesselSelections.get(logbookId)
|
||||
if (!record) return emptyLogbookVesselSelection()
|
||||
|
||||
const key = await resolveLogbookKey(logbookId)
|
||||
const data = (await decryptJson(record.encryptedData, record.iv, record.tag, key)) as
|
||||
| LogbookVesselSelectionData
|
||||
| null
|
||||
if (!data) return emptyLogbookVesselSelection()
|
||||
|
||||
return {
|
||||
activeVesselId: data.activeVesselId ?? null,
|
||||
vesselSnapshot: data.vesselSnapshot ?? null
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveLogbookVesselSelection(
|
||||
logbookId: string,
|
||||
selection: LogbookVesselSelectionData
|
||||
): Promise<void> {
|
||||
const key = await resolveLogbookKey(logbookId)
|
||||
const encrypted = await encryptJson(selection, key)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
await db.logbookVesselSelections.put({
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
await db.syncQueue.put({
|
||||
action: 'update',
|
||||
type: 'logbookVessel',
|
||||
payloadId: logbookId,
|
||||
logbookId,
|
||||
data: JSON.stringify(encrypted),
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
}
|
||||
|
||||
export async function saveLogbookVesselSelectionFromId(
|
||||
logbookId: string,
|
||||
activeVesselId: string | null,
|
||||
poolOverride?: Map<string, VesselData>
|
||||
): Promise<LogbookVesselSelectionData> {
|
||||
const pool = poolOverride ?? (await loadVesselPoolMap())
|
||||
const selection = buildLogbookVesselSelection(activeVesselId, pool)
|
||||
await saveLogbookVesselSelection(logbookId, selection)
|
||||
return selection
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -31,20 +31,15 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
|
||||
throw new Error('Encryption key not found. Please log in.')
|
||||
}
|
||||
|
||||
// 1. Fetch Yacht details
|
||||
const yachtRecord = await db.yachts.get(logbookId);
|
||||
if (yachtRecord) {
|
||||
try {
|
||||
const yacht = await decryptJson(yachtRecord.encryptedData, yachtRecord.iv, yachtRecord.tag, masterKey);
|
||||
yachtName = yacht.name || '';
|
||||
homePort = yacht.port || '';
|
||||
registration = yacht.registrationNumber || yacht.registration || '';
|
||||
callsign = yacht.callSign || '';
|
||||
atis = yacht.atis || '';
|
||||
mmsi = yacht.mmsi || '';
|
||||
} catch (e) {
|
||||
console.error('Failed to decrypt yacht details for PDF:', e);
|
||||
}
|
||||
const { resolveVesselForLogbook } = await import('./resolveVessel.js')
|
||||
const yacht = await resolveVesselForLogbook(logbookId)
|
||||
if (yacht) {
|
||||
yachtName = yacht.name || ''
|
||||
homePort = yacht.homePort || ''
|
||||
registration = yacht.registrationNumber || ''
|
||||
callsign = yacht.callSign || ''
|
||||
atis = yacht.atis || ''
|
||||
mmsi = yacht.mmsi || ''
|
||||
}
|
||||
|
||||
// 2. Fetch active Entry
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { decryptJson, encryptJson } from './crypto.js'
|
||||
import type { PersonData } from '../types/person.js'
|
||||
import { MAX_POOL_CREW_MEMBERS } from '../types/person.js'
|
||||
import { syncPersonPool } from './personPoolSync.js'
|
||||
|
||||
export interface DecryptedPerson {
|
||||
payloadId: string
|
||||
data: PersonData
|
||||
}
|
||||
|
||||
function requireMasterKey(): ArrayBuffer {
|
||||
const key = getActiveMasterKey()
|
||||
if (!key) throw new Error('Encryption key not found. Please log in.')
|
||||
return key
|
||||
}
|
||||
|
||||
export async function loadPersonPool(): Promise<DecryptedPerson[]> {
|
||||
const masterKey = requireMasterKey()
|
||||
const records = await db.personPool.toArray()
|
||||
const result: DecryptedPerson[] = []
|
||||
|
||||
for (const record of records) {
|
||||
const data = (await decryptJson(record.encryptedData, record.iv, record.tag, masterKey)) as
|
||||
| PersonData
|
||||
| null
|
||||
if (data) {
|
||||
result.push({ payloadId: record.payloadId, data })
|
||||
}
|
||||
}
|
||||
|
||||
result.sort((a, b) => {
|
||||
if (a.data.role !== b.data.role) return a.data.role === 'skipper' ? -1 : 1
|
||||
return a.data.name.localeCompare(b.data.name, undefined, { sensitivity: 'base' })
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export async function loadPersonPoolMap(): Promise<Map<string, PersonData>> {
|
||||
const people = await loadPersonPool()
|
||||
return new Map(people.map((p) => [p.payloadId, p.data]))
|
||||
}
|
||||
|
||||
export async function savePerson(
|
||||
payloadId: string,
|
||||
data: PersonData,
|
||||
isNew: boolean
|
||||
): Promise<void> {
|
||||
if (data.role === 'crew' && isNew) {
|
||||
const crewCount = await db.personPool
|
||||
.toArray()
|
||||
.then(async (rows) => {
|
||||
let count = 0
|
||||
const masterKey = requireMasterKey()
|
||||
for (const row of rows) {
|
||||
const dec = (await decryptJson(row.encryptedData, row.iv, row.tag, masterKey)) as PersonData | null
|
||||
if (dec?.role === 'crew') count++
|
||||
}
|
||||
return count
|
||||
})
|
||||
if (crewCount >= MAX_POOL_CREW_MEMBERS) {
|
||||
throw new Error('MAX_CREW')
|
||||
}
|
||||
}
|
||||
|
||||
const masterKey = requireMasterKey()
|
||||
const encrypted = await encryptJson(data, masterKey)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
await db.personPool.put({
|
||||
payloadId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
await db.userSyncQueue.put({
|
||||
action: isNew ? 'create' : 'update',
|
||||
type: 'person',
|
||||
payloadId,
|
||||
data: JSON.stringify(encrypted),
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
syncPersonPool().catch((err) => console.warn('Person pool sync failed:', err))
|
||||
}
|
||||
|
||||
export async function deletePerson(payloadId: string): Promise<void> {
|
||||
const now = new Date().toISOString()
|
||||
await db.personPool.delete(payloadId)
|
||||
await db.userSyncQueue.put({
|
||||
action: 'delete',
|
||||
type: 'person',
|
||||
payloadId,
|
||||
data: '',
|
||||
updatedAt: now
|
||||
})
|
||||
syncPersonPool().catch((err) => console.warn('Person pool sync failed:', err))
|
||||
}
|
||||
|
||||
export function filterSkippers(people: DecryptedPerson[]): DecryptedPerson[] {
|
||||
return people.filter((p) => p.data.role === 'skipper')
|
||||
}
|
||||
|
||||
export function filterCrew(people: DecryptedPerson[]): DecryptedPerson[] {
|
||||
return people.filter((p) => p.data.role === 'crew')
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { apiFetch } from './api.js'
|
||||
|
||||
const API_BASE = '/api/auth/person-pool'
|
||||
|
||||
function isNewer(timeA: string | Date, timeB: string | Date): boolean {
|
||||
return new Date(timeA).getTime() > new Date(timeB).getTime()
|
||||
}
|
||||
|
||||
export async function syncPersonPool(): Promise<void> {
|
||||
if (!navigator.onLine || !getActiveMasterKey() || !localStorage.getItem('active_userid')) return
|
||||
|
||||
await pushPersonPool()
|
||||
await pullPersonPool()
|
||||
}
|
||||
|
||||
async function pushPersonPool(): Promise<void> {
|
||||
const pending = (await db.userSyncQueue.toArray()).filter((item) => item.type === 'person')
|
||||
if (pending.length === 0) return
|
||||
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/push`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ items: pending })
|
||||
})
|
||||
if (!response.ok) {
|
||||
console.warn('Person pool push rejected')
|
||||
return
|
||||
}
|
||||
|
||||
const { results } = await response.json()
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const res = results[i]
|
||||
const item = pending[i]
|
||||
if (!item) continue
|
||||
if (res.status === 'success' && item.id !== undefined) {
|
||||
await db.userSyncQueue.delete(item.id)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Person pool push failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function pullPersonPool(): Promise<void> {
|
||||
try {
|
||||
const response = await apiFetch(API_BASE, { method: 'GET' })
|
||||
if (!response.ok) return
|
||||
|
||||
const { persons } = await response.json()
|
||||
if (!Array.isArray(persons)) return
|
||||
|
||||
const serverMap = new Map<string, (typeof persons)[0]>()
|
||||
for (const p of persons) {
|
||||
serverMap.set(p.payloadId, p)
|
||||
const local = await db.personPool.get(p.payloadId)
|
||||
if (!local || isNewer(p.updatedAt, local.updatedAt)) {
|
||||
await db.personPool.put({
|
||||
payloadId: p.payloadId,
|
||||
encryptedData: p.encryptedData,
|
||||
iv: p.iv,
|
||||
tag: p.tag,
|
||||
updatedAt: p.updatedAt
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const localAll = await db.personPool.toArray()
|
||||
for (const local of localAll) {
|
||||
if (!serverMap.has(local.payloadId)) {
|
||||
const pendingCreate = await db.userSyncQueue
|
||||
.where({ payloadId: local.payloadId, action: 'create' })
|
||||
.first()
|
||||
if (!pendingCreate) {
|
||||
await db.personPool.delete(local.payloadId)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Person pool pull failed:', err)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -27,17 +27,62 @@ export function getNotificationPermission(): NotificationPermission | 'unsupport
|
||||
return Notification.permission
|
||||
}
|
||||
|
||||
let cachedVapidKey: string | null = null
|
||||
let cachedRegistration: ServiceWorkerRegistration | null = null
|
||||
|
||||
async function getRegistrationCompat(timeoutMs = 8000): Promise<ServiceWorkerRegistration> {
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
throw new Error('Service Worker is not supported by your browser')
|
||||
}
|
||||
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.getRegistration()
|
||||
if (reg) return reg
|
||||
} catch (e) {
|
||||
console.warn('Failed to get service worker registration directly:', e)
|
||||
}
|
||||
|
||||
// Fallback to waiting for ready state with a timeout
|
||||
const readyPromise = navigator.serviceWorker.ready
|
||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Timeout waiting for Service Worker ready state')), timeoutMs)
|
||||
)
|
||||
|
||||
return Promise.race([readyPromise, timeoutPromise])
|
||||
}
|
||||
|
||||
export async function preloadPushService(): Promise<void> {
|
||||
if (!isPushSupported()) return
|
||||
try {
|
||||
if (!cachedVapidKey) {
|
||||
await fetchVapidPublicKey()
|
||||
}
|
||||
if (!cachedRegistration) {
|
||||
cachedRegistration = await getRegistrationCompat()
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to preload push service:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchVapidPublicKey(): Promise<string | null> {
|
||||
if (cachedVapidKey) return cachedVapidKey
|
||||
|
||||
const envKey = import.meta.env.VITE_VAPID_PUBLIC_KEY
|
||||
if (typeof envKey === 'string' && envKey.trim()) {
|
||||
return envKey.trim()
|
||||
cachedVapidKey = envKey.trim()
|
||||
return cachedVapidKey
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/vapid-public-key`)
|
||||
if (!res.ok) return null
|
||||
const data = await res.json()
|
||||
return typeof data.publicKey === 'string' ? data.publicKey : null
|
||||
if (typeof data.publicKey === 'string') {
|
||||
cachedVapidKey = data.publicKey.trim()
|
||||
return cachedVapidKey
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@@ -72,11 +117,61 @@ export async function savePushPrefs(collaboratorChangesEnabled: boolean): Promis
|
||||
})
|
||||
}
|
||||
|
||||
async function requestNotificationPermission(): Promise<NotificationPermission> {
|
||||
if (typeof Notification === 'undefined') return 'denied'
|
||||
|
||||
// Try promise-based signature first
|
||||
try {
|
||||
const result = Notification.requestPermission()
|
||||
if (result !== undefined) {
|
||||
return await result
|
||||
}
|
||||
} catch {
|
||||
// Ignore and fall back to callback
|
||||
}
|
||||
|
||||
// Callback-based fallback
|
||||
return new Promise<NotificationPermission>((resolve) => {
|
||||
Notification.requestPermission((permission) => {
|
||||
resolve(permission)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function saveSubscriptionToServer(subscription: PushSubscription): Promise<void> {
|
||||
if (!localStorage.getItem('active_userid')) throw new Error('Not authenticated')
|
||||
|
||||
const endpoint = subscription.endpoint
|
||||
const json = subscription.toJSON()
|
||||
if (!json.endpoint || !json.keys?.p256dh || !json.keys?.auth) {
|
||||
let p256dh = json.keys?.p256dh
|
||||
let auth = json.keys?.auth
|
||||
|
||||
// Fallback for browsers (like Safari) that might not serialize keys in toJSON()
|
||||
if (!p256dh && typeof subscription.getKey === 'function') {
|
||||
try {
|
||||
const rawKey = subscription.getKey('p256dh')
|
||||
if (rawKey) {
|
||||
p256dh = btoa(String.fromCharCode(...new Uint8Array(rawKey)))
|
||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to extract p256dh key manually:', e)
|
||||
}
|
||||
}
|
||||
|
||||
if (!auth && typeof subscription.getKey === 'function') {
|
||||
try {
|
||||
const rawAuth = subscription.getKey('auth')
|
||||
if (rawAuth) {
|
||||
auth = btoa(String.fromCharCode(...new Uint8Array(rawAuth)))
|
||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to extract auth key manually:', e)
|
||||
}
|
||||
}
|
||||
|
||||
if (!endpoint || !p256dh || !auth) {
|
||||
throw new Error('Invalid push subscription')
|
||||
}
|
||||
|
||||
@@ -85,8 +180,8 @@ async function saveSubscriptionToServer(subscription: PushSubscription): Promise
|
||||
await apiJson(`${API_BASE}/subscription`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
endpoint: json.endpoint,
|
||||
keys: json.keys,
|
||||
endpoint,
|
||||
keys: { p256dh, auth },
|
||||
locale,
|
||||
userAgent: navigator.userAgent
|
||||
})
|
||||
@@ -98,35 +193,48 @@ export async function subscribeToPush(): Promise<void> {
|
||||
throw new Error('Push notifications are not supported on this device')
|
||||
}
|
||||
|
||||
const permission = await Notification.requestPermission()
|
||||
if (permission !== 'granted') {
|
||||
throw new Error('Notification permission denied')
|
||||
// Pre-resolve registration using getRegistrationCompat to prevent ready state hangs
|
||||
let registration = cachedRegistration
|
||||
if (!registration) {
|
||||
registration = await getRegistrationCompat()
|
||||
cachedRegistration = registration
|
||||
}
|
||||
|
||||
const publicKey = await fetchVapidPublicKey()
|
||||
const publicKey = cachedVapidKey || await fetchVapidPublicKey()
|
||||
if (!publicKey) {
|
||||
throw new Error('Push notifications are not configured on this server')
|
||||
}
|
||||
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
let subscription = await registration.pushManager.getSubscription()
|
||||
|
||||
if (!subscription) {
|
||||
const keyBytes = urlBase64ToUint8Array(publicKey)
|
||||
const applicationServerKey = new Uint8Array(keyBytes)
|
||||
subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey
|
||||
})
|
||||
const permission = await requestNotificationPermission()
|
||||
if (permission !== 'granted') {
|
||||
throw new Error('Notification permission denied')
|
||||
}
|
||||
|
||||
const keyBytes = urlBase64ToUint8Array(publicKey)
|
||||
const applicationServerKey = new Uint8Array(keyBytes)
|
||||
|
||||
// Always call subscribe with timeout to prevent silent hangs on push network errors
|
||||
const subscribePromise = registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey
|
||||
})
|
||||
const subscribeTimeout = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Timeout establishing subscription with push service (FCM/APNs)')), 12000)
|
||||
)
|
||||
const subscription = await Promise.race([subscribePromise, subscribeTimeout])
|
||||
|
||||
await saveSubscriptionToServer(subscription)
|
||||
}
|
||||
|
||||
export async function unsubscribeFromPush(): Promise<void> {
|
||||
if (!isPushSupported()) return
|
||||
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
let registration = cachedRegistration
|
||||
if (!registration) {
|
||||
registration = await getRegistrationCompat()
|
||||
cachedRegistration = registration
|
||||
}
|
||||
|
||||
const subscription = await registration.pushManager.getSubscription()
|
||||
if (!subscription) return
|
||||
|
||||
@@ -164,3 +272,7 @@ export async function disableCollaboratorChangePush(): Promise<void> {
|
||||
await savePushPrefs(false)
|
||||
await unsubscribeFromPush()
|
||||
}
|
||||
|
||||
if (isPushSupported()) {
|
||||
void preloadPushService()
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
forcePwaRecovery,
|
||||
markReloadAttempt,
|
||||
recentlyAttemptedReload,
|
||||
reconcileServiceWorkerOnStartup
|
||||
reconcileServiceWorkerOnStartup,
|
||||
reconcileVersionOnStartup
|
||||
} from './pwaStartup.js'
|
||||
|
||||
const STALE_RECOVERY_COUNT_KEY = 'pwa_stale_recovery_count'
|
||||
const STALE_RECOVERY_LAST_KEY = 'pwa_stale_recovery_last_ts'
|
||||
|
||||
describe('pwaStartup reload guards', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
@@ -18,6 +23,45 @@ describe('pwaStartup reload guards', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('forcePwaRecovery stale counter reset', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
vi.unstubAllEnvs()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('clears stale recovery counter before hard recovery reload', async () => {
|
||||
vi.stubEnv('DEV', false)
|
||||
sessionStorage.setItem(STALE_RECOVERY_COUNT_KEY, '2')
|
||||
sessionStorage.setItem(STALE_RECOVERY_LAST_KEY, String(Date.now()))
|
||||
|
||||
const reload = vi.fn()
|
||||
vi.stubGlobal('location', { reload })
|
||||
vi.stubGlobal('caches', {
|
||||
keys: vi.fn().mockResolvedValue([]),
|
||||
delete: vi.fn()
|
||||
})
|
||||
Object.defineProperty(navigator, 'serviceWorker', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getRegistrations: vi.fn().mockResolvedValue([])
|
||||
}
|
||||
})
|
||||
|
||||
await forcePwaRecovery()
|
||||
|
||||
expect(sessionStorage.getItem(STALE_RECOVERY_COUNT_KEY)).toBeNull()
|
||||
expect(sessionStorage.getItem(STALE_RECOVERY_LAST_KEY)).toBeNull()
|
||||
expect(reload).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('returns false when hard recovery was just attempted', async () => {
|
||||
sessionStorage.setItem('pwa_hard_recovery_ts', String(Date.now()))
|
||||
const result = await forcePwaRecovery()
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reconcileServiceWorkerOnStartup', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
@@ -35,11 +79,68 @@ describe('reconcileServiceWorkerOnStartup', () => {
|
||||
configurable: true,
|
||||
value: {
|
||||
controller: {},
|
||||
getRegistration: vi.fn().mockResolvedValue({ waiting: null }),
|
||||
getRegistration: vi.fn().mockResolvedValue({
|
||||
waiting: null,
|
||||
installing: null,
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
addEventListener: vi.fn()
|
||||
}),
|
||||
addEventListener: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
await expect(reconcileServiceWorkerOnStartup()).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when waiting worker activation never takes over', async () => {
|
||||
vi.useFakeTimers()
|
||||
const postMessage = vi.fn()
|
||||
const addEventListener = vi.fn()
|
||||
vi.stubEnv('DEV', false)
|
||||
|
||||
Object.defineProperty(navigator, 'serviceWorker', {
|
||||
configurable: true,
|
||||
value: {
|
||||
controller: { scriptURL: '/sw.js?v=1' },
|
||||
getRegistration: vi.fn().mockResolvedValue({
|
||||
waiting: { postMessage },
|
||||
installing: null,
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
addEventListener: vi.fn()
|
||||
}),
|
||||
addEventListener
|
||||
}
|
||||
})
|
||||
|
||||
const reconcilePromise = reconcileServiceWorkerOnStartup()
|
||||
await vi.advanceTimersByTimeAsync(4_000)
|
||||
|
||||
await expect(reconcilePromise).resolves.toBe(false)
|
||||
expect(postMessage).toHaveBeenCalledWith({ type: 'SKIP_WAITING' })
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
describe('reconcileVersionOnStartup', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
vi.unstubAllEnvs()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns noop in dev mode', async () => {
|
||||
vi.stubEnv('DEV', true)
|
||||
await expect(reconcileVersionOnStartup()).resolves.toBe('noop')
|
||||
})
|
||||
|
||||
it('returns noop when deployed version matches bundled version', async () => {
|
||||
vi.stubEnv('DEV', false)
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ version: '0.1.0.57' })
|
||||
}))
|
||||
vi.stubGlobal('__APP_VERSION__', '0.1.0.57')
|
||||
|
||||
await expect(reconcileVersionOnStartup()).resolves.toBe('noop')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { isNewerAppVersion, fetchDeployedVersion, getAppVersion } from './pwaVersion.js'
|
||||
|
||||
const RELOAD_ATTEMPT_KEY = 'pwa_reload_attempt_ts'
|
||||
const COLD_START_UPDATE_KEY = 'pwa_coldstart_update_ts'
|
||||
const HARD_RECOVERY_KEY = 'pwa_hard_recovery_ts'
|
||||
const STALE_RECOVERY_COUNT_KEY = 'pwa_stale_recovery_count'
|
||||
const STALE_RECOVERY_LAST_KEY = 'pwa_stale_recovery_last_ts'
|
||||
const STALE_RECOVERY_WINDOW_MS = 60_000
|
||||
const RELOAD_DEBOUNCE_MS = 4_000
|
||||
const COLD_START_UPDATE_DEBOUNCE_MS = 15_000
|
||||
const HARD_RECOVERY_DEBOUNCE_MS = 30_000
|
||||
|
||||
export function recentlyAttemptedReload(now = Date.now()): boolean {
|
||||
const last = Number(sessionStorage.getItem(RELOAD_ATTEMPT_KEY) || '0')
|
||||
@@ -21,6 +28,34 @@ function markColdStartUpdateAttempt(now = Date.now()): void {
|
||||
sessionStorage.setItem(COLD_START_UPDATE_KEY, String(now))
|
||||
}
|
||||
|
||||
function recentlyAttemptedHardRecovery(now = Date.now()): boolean {
|
||||
const last = Number(sessionStorage.getItem(HARD_RECOVERY_KEY) || '0')
|
||||
return now - last < HARD_RECOVERY_DEBOUNCE_MS
|
||||
}
|
||||
|
||||
function markHardRecoveryAttempt(now = Date.now()): void {
|
||||
sessionStorage.setItem(HARD_RECOVERY_KEY, String(now))
|
||||
}
|
||||
|
||||
function resetStaleRecoveryCount(): void {
|
||||
sessionStorage.removeItem(STALE_RECOVERY_COUNT_KEY)
|
||||
sessionStorage.removeItem(STALE_RECOVERY_LAST_KEY)
|
||||
}
|
||||
|
||||
function incrementStaleRecoveryCount(now = Date.now()): number {
|
||||
const last = Number(sessionStorage.getItem(STALE_RECOVERY_LAST_KEY) || '0')
|
||||
let current = Number(sessionStorage.getItem(STALE_RECOVERY_COUNT_KEY) || '0')
|
||||
|
||||
if (now - last > STALE_RECOVERY_WINDOW_MS) {
|
||||
current = 0
|
||||
}
|
||||
|
||||
const next = current + 1
|
||||
sessionStorage.setItem(STALE_RECOVERY_COUNT_KEY, String(next))
|
||||
sessionStorage.setItem(STALE_RECOVERY_LAST_KEY, String(now))
|
||||
return next
|
||||
}
|
||||
|
||||
function isStaleModuleLoadError(error: unknown): boolean {
|
||||
const message =
|
||||
error instanceof Error
|
||||
@@ -32,10 +67,120 @@ function isStaleModuleLoadError(error: unknown): boolean {
|
||||
return (
|
||||
message.includes('Failed to fetch dynamically imported module') ||
|
||||
message.includes('Importing a module script failed') ||
|
||||
message.includes('error loading dynamically imported module')
|
||||
message.includes('error loading dynamically imported module') ||
|
||||
message.includes('Loading chunk') ||
|
||||
message.includes('ChunkLoadError') ||
|
||||
message.includes('Unable to preload CSS')
|
||||
)
|
||||
}
|
||||
|
||||
export async function clearPwaCachesAndWorkers(): Promise<void> {
|
||||
if ('serviceWorker' in navigator) {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations()
|
||||
await Promise.all(registrations.map((registration) => registration.unregister()))
|
||||
}
|
||||
|
||||
if ('caches' in window) {
|
||||
const keys = await caches.keys()
|
||||
await Promise.all(keys.map((key) => caches.delete(key)))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Last-resort recovery when soft reloads cannot escape a stale precache.
|
||||
* Equivalent to manually clearing site data / reinstalling the PWA.
|
||||
*/
|
||||
export async function forcePwaRecovery(): Promise<boolean> {
|
||||
if (recentlyAttemptedHardRecovery()) return false
|
||||
|
||||
markHardRecoveryAttempt()
|
||||
markReloadAttempt()
|
||||
resetStaleRecoveryCount()
|
||||
await clearPwaCachesAndWorkers()
|
||||
window.location.reload()
|
||||
return true
|
||||
}
|
||||
|
||||
async function waitForWaitingWorker(
|
||||
registration: ServiceWorkerRegistration,
|
||||
timeoutMs: number
|
||||
): Promise<ServiceWorker | null> {
|
||||
if (registration.waiting) {
|
||||
return registration.waiting
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const timeoutId = window.setTimeout(() => resolve(null), timeoutMs)
|
||||
|
||||
const inspectWorker = (worker: ServiceWorker | null) => {
|
||||
if (!worker) return
|
||||
|
||||
if (worker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||
window.clearTimeout(timeoutId)
|
||||
resolve(worker)
|
||||
return
|
||||
}
|
||||
|
||||
worker.addEventListener(
|
||||
'statechange',
|
||||
() => {
|
||||
if (worker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||
window.clearTimeout(timeoutId)
|
||||
resolve(worker)
|
||||
}
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
}
|
||||
|
||||
inspectWorker(registration.installing)
|
||||
|
||||
registration.addEventListener(
|
||||
'updatefound',
|
||||
() => {
|
||||
inspectWorker(registration.installing)
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export async function triggerServiceWorkerUpdate(timeoutMs = 5_000): Promise<boolean> {
|
||||
if (import.meta.env.DEV || !('serviceWorker' in navigator)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const registration = await navigator.serviceWorker.getRegistration()
|
||||
if (!registration) return false
|
||||
|
||||
try {
|
||||
await registration.update()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
const waiting = await waitForWaitingWorker(registration, timeoutMs)
|
||||
return waiting !== null
|
||||
}
|
||||
|
||||
async function activateWaitingWorker(waiting: ServiceWorker): Promise<boolean> {
|
||||
const currentController = navigator.serviceWorker.controller?.scriptURL ?? null
|
||||
waiting.postMessage({ type: 'SKIP_WAITING' })
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const timeoutId = window.setTimeout(() => resolve(false), 4_000)
|
||||
navigator.serviceWorker.addEventListener(
|
||||
'controllerchange',
|
||||
() => {
|
||||
window.clearTimeout(timeoutId)
|
||||
const nextController = navigator.serviceWorker.controller?.scriptURL ?? null
|
||||
resolve(nextController !== null && nextController !== currentController)
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* After missed deploys, a waiting SW may exist while the page still runs an old bundle.
|
||||
* Apply the waiting worker once on cold start (one controlled reload) instead of hanging.
|
||||
@@ -50,38 +195,95 @@ export async function reconcileServiceWorkerOnStartup(): Promise<boolean> {
|
||||
}
|
||||
|
||||
const registration = await navigator.serviceWorker.getRegistration()
|
||||
const waiting = registration?.waiting
|
||||
let waiting = registration?.waiting ?? null
|
||||
|
||||
if (!waiting && registration) {
|
||||
await registration.update().catch(() => {})
|
||||
waiting = await waitForWaitingWorker(registration, 4_000)
|
||||
}
|
||||
|
||||
if (!waiting || !navigator.serviceWorker.controller) {
|
||||
return false
|
||||
}
|
||||
|
||||
markColdStartUpdateAttempt()
|
||||
waiting.postMessage({ type: 'SKIP_WAITING' })
|
||||
const activated = await activateWaitingWorker(waiting)
|
||||
if (activated) {
|
||||
markColdStartUpdateAttempt()
|
||||
}
|
||||
return activated
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeoutId = window.setTimeout(resolve, 4_000)
|
||||
navigator.serviceWorker.addEventListener(
|
||||
'controllerchange',
|
||||
() => {
|
||||
window.clearTimeout(timeoutId)
|
||||
resolve()
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
/**
|
||||
* Compare deployed version.json with the bundled app version.
|
||||
* When the server is ahead, try a soft SW takeover before hard recovery.
|
||||
*/
|
||||
export async function reconcileVersionOnStartup(): Promise<'reload' | 'recovered' | 'noop'> {
|
||||
if (import.meta.env.DEV || !navigator.onLine) {
|
||||
return 'noop'
|
||||
}
|
||||
|
||||
return true
|
||||
const deployedVersion = await fetchDeployedVersion()
|
||||
if (!deployedVersion || !isNewerAppVersion(deployedVersion, getAppVersion())) {
|
||||
return 'noop'
|
||||
}
|
||||
|
||||
const reconciled = await reconcileServiceWorkerOnStartup()
|
||||
if (reconciled) {
|
||||
return 'reload'
|
||||
}
|
||||
|
||||
const updated = await triggerServiceWorkerUpdate()
|
||||
if (updated) {
|
||||
const registration = await navigator.serviceWorker.getRegistration()
|
||||
const waiting = registration?.waiting
|
||||
if (waiting) {
|
||||
const activated = await activateWaitingWorker(waiting)
|
||||
if (activated) {
|
||||
markColdStartUpdateAttempt()
|
||||
return 'reload'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!recentlyAttemptedHardRecovery()) {
|
||||
const recovered = await forcePwaRecovery()
|
||||
if (recovered) {
|
||||
return 'recovered'
|
||||
}
|
||||
}
|
||||
|
||||
return 'noop'
|
||||
}
|
||||
|
||||
export function installStaleAssetRecovery(): void {
|
||||
if (import.meta.env.DEV) return
|
||||
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
if (!isStaleModuleLoadError(event.reason)) return
|
||||
const recoverFromStaleAssets = () => {
|
||||
if (recentlyAttemptedReload()) return
|
||||
|
||||
const attempts = incrementStaleRecoveryCount()
|
||||
markReloadAttempt()
|
||||
event.preventDefault()
|
||||
|
||||
if (attempts >= 2) {
|
||||
void forcePwaRecovery()
|
||||
return
|
||||
}
|
||||
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
if (!isStaleModuleLoadError(event.reason)) return
|
||||
event.preventDefault()
|
||||
recoverFromStaleAssets()
|
||||
})
|
||||
|
||||
window.addEventListener(
|
||||
'error',
|
||||
(event) => {
|
||||
if (!isStaleModuleLoadError(event.message)) return
|
||||
recoverFromStaleAssets()
|
||||
},
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
compareAppVersions,
|
||||
isNewerAppVersion,
|
||||
parseAppVersion
|
||||
} from './pwaVersion.js'
|
||||
|
||||
describe('pwaVersion', () => {
|
||||
it('parses semantic build versions', () => {
|
||||
expect(parseAppVersion('v0.1.0.57')).toEqual([0, 1, 0, 57])
|
||||
})
|
||||
|
||||
it('compares build numbers numerically', () => {
|
||||
expect(compareAppVersions('0.1.0.65', '0.1.0.57')).toBeGreaterThan(0)
|
||||
expect(compareAppVersions('0.1.0.57', '0.1.0.65')).toBeLessThan(0)
|
||||
expect(compareAppVersions('0.1.0.57', '0.1.0.57')).toBe(0)
|
||||
})
|
||||
|
||||
it('detects newer deployed versions', () => {
|
||||
expect(isNewerAppVersion('0.1.0.66', '0.1.0.57')).toBe(true)
|
||||
expect(isNewerAppVersion('0.1.0.57', '0.1.0.57')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
const APP_VERSION =
|
||||
typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0.0-dev'
|
||||
|
||||
export function getAppVersion(): string {
|
||||
return APP_VERSION
|
||||
}
|
||||
|
||||
export function parseAppVersion(version: string): number[] {
|
||||
return version
|
||||
.replace(/^v/i, '')
|
||||
.split('.')
|
||||
.map((part) => Number.parseInt(part, 10) || 0)
|
||||
}
|
||||
|
||||
/** Positive when `a` is newer than `b`. */
|
||||
export function compareAppVersions(a: string, b: string): number {
|
||||
const partsA = parseAppVersion(a)
|
||||
const partsB = parseAppVersion(b)
|
||||
const length = Math.max(partsA.length, partsB.length)
|
||||
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const diff = (partsA[index] ?? 0) - (partsB[index] ?? 0)
|
||||
if (diff !== 0) return diff
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
export function isNewerAppVersion(serverVersion: string, clientVersion: string): boolean {
|
||||
return compareAppVersions(serverVersion, clientVersion) > 0
|
||||
}
|
||||
|
||||
export async function fetchDeployedVersion(timeoutMs = 4_000): Promise<string | null> {
|
||||
if (!navigator.onLine) return null
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/version.json?_=${Date.now()}`, {
|
||||
cache: 'no-store',
|
||||
signal: controller.signal
|
||||
})
|
||||
if (!response.ok) return null
|
||||
|
||||
const payload = (await response.json()) as { version?: unknown }
|
||||
return typeof payload.version === 'string' ? payload.version.trim() : null
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
export async function isDeployedVersionNewer(): Promise<boolean> {
|
||||
const deployedVersion = await fetchDeployedVersion()
|
||||
if (!deployedVersion) return false
|
||||
return isNewerAppVersion(deployedVersion, getAppVersion())
|
||||
}
|
||||
@@ -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,395 @@
|
||||
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 { putEntryRecord } from '../utils/entryListCache.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 putEntryRecord(
|
||||
{
|
||||
payloadId: localId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: nowStr
|
||||
},
|
||||
initialPayload
|
||||
)
|
||||
|
||||
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 putEntryRecord(
|
||||
{
|
||||
payloadId: entryId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
},
|
||||
entryData
|
||||
)
|
||||
|
||||
await db.syncQueue.put({
|
||||
action: 'update',
|
||||
type: 'entry',
|
||||
payloadId: entryId,
|
||||
logbookId,
|
||||
data: JSON.stringify(encrypted),
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
}
|
||||
|
||||
export async function removeLastEvent(
|
||||
logbookId: string,
|
||||
entryId: string
|
||||
): Promise<LogEventPayload[]> {
|
||||
const loaded = await loadEntry(logbookId, entryId)
|
||||
if (!loaded) throw new Error('Entry not found')
|
||||
|
||||
const currentEvents = (loaded.data.events as LogEventPayload[]) || []
|
||||
if (currentEvents.length === 0) return []
|
||||
|
||||
const nextEvents = sortLogEventsByTime(currentEvents.slice(0, -1))
|
||||
await persistEntry(logbookId, entryId, loaded.data, { events: nextEvents })
|
||||
return nextEvents
|
||||
}
|
||||
|
||||
export async function appendTankRefill(
|
||||
logbookId: string,
|
||||
entryId: string,
|
||||
tank: 'fuel' | 'freshwater',
|
||||
addLiters: number,
|
||||
event: Partial<LogEventPayload>
|
||||
): Promise<AppendQuickEventResult> {
|
||||
const loaded = await loadEntry(logbookId, entryId)
|
||||
if (!loaded) throw new Error('Entry not found')
|
||||
|
||||
const { fw, fuel } = tankLevelsFromData(loaded.data)
|
||||
const currentEvents = (loaded.data.events as LogEventPayload[]) || []
|
||||
const newEvent = normalizeLogEvent({
|
||||
time: currentLocalTimeHHMM(),
|
||||
...event
|
||||
})
|
||||
const nextEvents = sortLogEventsByTime([...currentEvents, newEvent])
|
||||
|
||||
const tankPatch = tank === 'fuel'
|
||||
? {
|
||||
fuel: {
|
||||
morning: fuel.morning || 0,
|
||||
refilled: (fuel.refilled || 0) + addLiters,
|
||||
evening: fuel.evening || 0,
|
||||
consumption: fuel.consumption ?? 0
|
||||
}
|
||||
}
|
||||
: {
|
||||
freshwater: {
|
||||
morning: fw.morning || 0,
|
||||
refilled: (fw.refilled || 0) + addLiters,
|
||||
evening: fw.evening || 0,
|
||||
consumption: fw.consumption ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
const hadSignature = !!(loaded.data.signSkipper || loaded.data.signCrew)
|
||||
await persistEntry(logbookId, entryId, loaded.data, {
|
||||
events: nextEvents,
|
||||
...tankPatch,
|
||||
clearSignatures: hadSignature
|
||||
})
|
||||
|
||||
return { events: nextEvents, hadSignature }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { getLogbookKey } from './logbookKeys.js'
|
||||
import { decryptJson } from './crypto.js'
|
||||
import type { VesselData } from '../types/vessel.js'
|
||||
import { vesselDataFromSnapshot } from '../utils/vesselSnapshot.js'
|
||||
import { loadLogbookVesselSelection } from './logbookVesselSelection.js'
|
||||
import { loadVesselPoolMap } from './vesselPool.js'
|
||||
|
||||
/** Resolved vessel for a logbook: selection snapshot, pool, or legacy per-logbook yacht. */
|
||||
export async function resolveVesselForLogbook(
|
||||
logbookId: string,
|
||||
options?: {
|
||||
preloadedYacht?: VesselData | Record<string, unknown> | null
|
||||
preloadedSelection?: import('../types/vessel.js').LogbookVesselSelectionData
|
||||
}
|
||||
): Promise<VesselData | null> {
|
||||
if (options?.preloadedYacht) {
|
||||
return options.preloadedYacht as VesselData
|
||||
}
|
||||
|
||||
const selection =
|
||||
options?.preloadedSelection ?? (logbookId === 'demo' ? null : await loadLogbookVesselSelection(logbookId))
|
||||
|
||||
if (selection?.vesselSnapshot) {
|
||||
return vesselDataFromSnapshot(selection.vesselSnapshot)
|
||||
}
|
||||
|
||||
if (selection?.activeVesselId && logbookId !== 'demo') {
|
||||
const pool = await loadVesselPoolMap()
|
||||
const fromPool = pool.get(selection.activeVesselId)
|
||||
if (fromPool) return fromPool
|
||||
}
|
||||
|
||||
const legacy = await db.yachts.get(logbookId)
|
||||
if (!legacy) return null
|
||||
|
||||
const key = (await getLogbookKey(logbookId)) || getActiveMasterKey()
|
||||
if (!key) return null
|
||||
|
||||
const decrypted = (await decryptJson(legacy.encryptedData, legacy.iv, legacy.tag, key)) as
|
||||
| VesselData
|
||||
| null
|
||||
return decrypted
|
||||
}
|
||||
+138
-18
@@ -2,6 +2,13 @@ import { db, type SyncQueueItem } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { apiFetch } from './api.js'
|
||||
import { getLogbookAccess } from './logbookAccess.js'
|
||||
import {
|
||||
clearSyncConflict,
|
||||
reportSyncConflict,
|
||||
type SyncConflict
|
||||
} from './syncConflicts.js'
|
||||
import { syncPersonPool } from './personPoolSync.js'
|
||||
import { forEachInBatches, yieldToMain } from '../utils/yieldToMain.js'
|
||||
|
||||
const API_BASE = '/api/sync'
|
||||
const syncingLogbooks = new Set<string>()
|
||||
@@ -56,6 +63,10 @@ async function entityExistsLocally(item: SyncQueueItem): Promise<boolean> {
|
||||
return !!(await db.photos.get(item.payloadId))
|
||||
case 'gpsTrack':
|
||||
return !!(await db.gpsTracks.get(item.payloadId))
|
||||
case 'logbookCrew':
|
||||
return !!(await db.logbookCrewSelections.get(item.logbookId))
|
||||
case 'logbookVessel':
|
||||
return !!(await db.logbookVesselSelections.get(item.logbookId))
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -120,12 +131,7 @@ async function coalesceSyncQueue(logbookId: string): Promise<SyncQueueItem[]> {
|
||||
}
|
||||
|
||||
function scheduleResync(logbookId: string) {
|
||||
if (pendingResync.has(logbookId)) return
|
||||
pendingResync.add(logbookId)
|
||||
queueMicrotask(() => {
|
||||
pendingResync.delete(logbookId)
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Deferred sync failed:', err))
|
||||
})
|
||||
}
|
||||
|
||||
type LogbookPushAccess = 'OWNER' | 'WRITE' | 'READ' | 'UNKNOWN'
|
||||
@@ -177,10 +183,19 @@ async function pushChanges(logbookId: string): Promise<boolean> {
|
||||
const queueItem = pending[i]
|
||||
if (!queueItem) continue
|
||||
|
||||
if (res.status === 'success' || res.status === 'conflict') {
|
||||
if (res.status === 'success') {
|
||||
if (queueItem.id !== undefined) {
|
||||
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 {
|
||||
console.error(`Sync failed for item ${res.payloadId}:`, res.error)
|
||||
}
|
||||
@@ -210,6 +225,8 @@ async function flushPushQueue(logbookId: string): Promise<boolean> {
|
||||
type PulledServerPayload = {
|
||||
yacht?: { updatedAt: string } | null
|
||||
deviation?: { updatedAt: string } | null
|
||||
logbookCrewSelection?: { updatedAt: string } | null
|
||||
logbookVesselSelection?: { updatedAt: string } | null
|
||||
crews?: Array<{ payloadId: string; updatedAt: string }>
|
||||
entries?: Array<{ payloadId: string; updatedAt: string }>
|
||||
photos?: Array<{ payloadId: string; updatedAt: string }>
|
||||
@@ -227,6 +244,12 @@ async function pruneAcknowledgedQueueItems(
|
||||
const serverTimes = new Map<string, string>()
|
||||
if (server.yacht) serverTimes.set('yacht:' + logbookId, server.yacht.updatedAt)
|
||||
if (server.deviation) serverTimes.set('deviation:' + logbookId, server.deviation.updatedAt)
|
||||
if (server.logbookCrewSelection) {
|
||||
serverTimes.set('logbookCrew:' + logbookId, server.logbookCrewSelection.updatedAt)
|
||||
}
|
||||
if (server.logbookVesselSelection) {
|
||||
serverTimes.set('logbookVessel:' + logbookId, server.logbookVesselSelection.updatedAt)
|
||||
}
|
||||
for (const c of server.crews ?? []) serverTimes.set('crew:' + c.payloadId, c.updatedAt)
|
||||
for (const e of server.entries ?? []) serverTimes.set('entry:' + e.payloadId, e.updatedAt)
|
||||
for (const p of server.photos ?? []) serverTimes.set('photo:' + p.payloadId, p.updatedAt)
|
||||
@@ -243,7 +266,14 @@ async function pruneAcknowledgedQueueItems(
|
||||
continue
|
||||
}
|
||||
|
||||
const key = item.type === 'yacht' ? 'yacht:' + logbookId : `${item.type}:${item.payloadId}`
|
||||
const key =
|
||||
item.type === 'yacht'
|
||||
? 'yacht:' + logbookId
|
||||
: item.type === 'logbookCrew'
|
||||
? 'logbookCrew:' + logbookId
|
||||
: item.type === 'logbookVessel'
|
||||
? 'logbookVessel:' + logbookId
|
||||
: `${item.type}:${item.payloadId}`
|
||||
const serverUpdatedAt = serverTimes.get(key)
|
||||
if (serverUpdatedAt && !isNewer(item.updatedAt, serverUpdatedAt)) {
|
||||
if (item.id !== undefined) staleIds.push(item.id)
|
||||
@@ -269,8 +299,22 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
||||
return false
|
||||
}
|
||||
|
||||
const { yacht, deviation, crews, entries, photos, gpsTracks } = await response.json()
|
||||
const serverSnapshot: PulledServerPayload = { yacht, deviation, crews, entries, photos, gpsTracks }
|
||||
const { yacht, deviation, crews, logbookCrewSelection, logbookVesselSelection, entries, photos, gpsTracks } =
|
||||
await response.json()
|
||||
|
||||
// Large pull payloads block on JSON.parse — yield before applying to IndexedDB.
|
||||
await yieldToMain()
|
||||
|
||||
const serverSnapshot: PulledServerPayload = {
|
||||
yacht,
|
||||
deviation,
|
||||
logbookCrewSelection,
|
||||
logbookVesselSelection,
|
||||
crews,
|
||||
entries,
|
||||
photos,
|
||||
gpsTracks
|
||||
}
|
||||
|
||||
// 1. Sync Yacht Payload
|
||||
if (yacht) {
|
||||
@@ -300,10 +344,38 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Sync Crew List Payloads
|
||||
// 2b. Sync Logbook Crew Selection
|
||||
if (logbookCrewSelection) {
|
||||
const local = await db.logbookCrewSelections.get(logbookId)
|
||||
if (!local || isNewer(logbookCrewSelection.updatedAt, local.updatedAt)) {
|
||||
await db.logbookCrewSelections.put({
|
||||
logbookId,
|
||||
encryptedData: logbookCrewSelection.encryptedData,
|
||||
iv: logbookCrewSelection.iv,
|
||||
tag: logbookCrewSelection.tag,
|
||||
updatedAt: logbookCrewSelection.updatedAt
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 2c. Sync Logbook Vessel Selection
|
||||
if (logbookVesselSelection) {
|
||||
const local = await db.logbookVesselSelections.get(logbookId)
|
||||
if (!local || isNewer(logbookVesselSelection.updatedAt, local.updatedAt)) {
|
||||
await db.logbookVesselSelections.put({
|
||||
logbookId,
|
||||
encryptedData: logbookVesselSelection.encryptedData,
|
||||
iv: logbookVesselSelection.iv,
|
||||
tag: logbookVesselSelection.tag,
|
||||
updatedAt: logbookVesselSelection.updatedAt
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Sync Crew List Payloads (legacy)
|
||||
const serverCrewMap = new Map<string, any>()
|
||||
if (crews && Array.isArray(crews)) {
|
||||
for (const c of crews) {
|
||||
await forEachInBatches(crews, 20, async (c) => {
|
||||
serverCrewMap.set(c.payloadId, c)
|
||||
const local = await db.crews.get(c.payloadId)
|
||||
if (!local || isNewer(c.updatedAt, local.updatedAt)) {
|
||||
@@ -316,7 +388,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
||||
updatedAt: c.updatedAt
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Deletions for Crew: If present locally but not on server, and not pending creation locally
|
||||
@@ -336,7 +408,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
||||
// 4. Sync Journal Entry Payloads
|
||||
const serverEntryMap = new Map<string, any>()
|
||||
if (entries && Array.isArray(entries)) {
|
||||
for (const e of entries) {
|
||||
await forEachInBatches(entries, 15, async (e) => {
|
||||
serverEntryMap.set(e.payloadId, e)
|
||||
const local = await db.entries.get(e.payloadId)
|
||||
if (!local || isNewer(e.updatedAt, local.updatedAt)) {
|
||||
@@ -349,7 +421,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
||||
updatedAt: e.updatedAt
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Deletions for Entries
|
||||
@@ -368,7 +440,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
||||
// 5. Sync Photos
|
||||
const serverPhotoMap = new Map<string, any>()
|
||||
if (photos && Array.isArray(photos)) {
|
||||
for (const p of photos) {
|
||||
await forEachInBatches(photos, 20, async (p) => {
|
||||
serverPhotoMap.set(p.payloadId, p)
|
||||
const local = await db.photos.get(p.payloadId)
|
||||
if (!local || isNewer(p.updatedAt, local.updatedAt)) {
|
||||
@@ -383,7 +455,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
||||
updatedAt: p.updatedAt
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Deletions for Photos
|
||||
@@ -402,7 +474,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
||||
// 6. Sync GPS Tracks
|
||||
const serverGpsTrackMap = new Map<string, any>()
|
||||
if (gpsTracks && Array.isArray(gpsTracks)) {
|
||||
for (const gt of gpsTracks) {
|
||||
await forEachInBatches(gpsTracks, 10, async (gt) => {
|
||||
serverGpsTrackMap.set(gt.entryId, gt)
|
||||
const local = await db.gpsTracks.get(gt.entryId)
|
||||
if (!local || isNewer(gt.updatedAt, local.updatedAt)) {
|
||||
@@ -415,7 +487,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
||||
updatedAt: gt.updatedAt
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Deletions for GPS Tracks
|
||||
@@ -463,6 +535,12 @@ export async function syncLogbook(logbookId: string): Promise<boolean> {
|
||||
} finally {
|
||||
syncingLogbooks.delete(logbookId)
|
||||
recomputeSyncingState()
|
||||
if (pendingResync.has(logbookId)) {
|
||||
pendingResync.delete(logbookId)
|
||||
setTimeout(() => {
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Deferred sync failed:', err))
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,6 +554,8 @@ export async function syncAllLogbooks(): Promise<void> {
|
||||
syncAllInFlight++
|
||||
recomputeSyncingState()
|
||||
try {
|
||||
await syncPersonPool()
|
||||
|
||||
// 1. Fetch latest logbook lists first (synchronizes db.logbooks index)
|
||||
const logbooks = await db.logbooks.toArray()
|
||||
|
||||
@@ -525,3 +605,43 @@ export function stopBackgroundSync() {
|
||||
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,80 @@
|
||||
import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { decryptJson, encryptJson } from './crypto.js'
|
||||
import { getLogbookKey } from './logbookKeys.js'
|
||||
import type { VesselData } from '../types/vessel.js'
|
||||
import { buildLogbookVesselSelection } from '../utils/vesselSnapshot.js'
|
||||
import { saveLogbookVesselSelection } from './logbookVesselSelection.js'
|
||||
|
||||
const MIGRATION_FLAG = 'vessel_pool_migration_v1_done'
|
||||
|
||||
function dedupeKey(data: VesselData): string {
|
||||
const reg = (data.registrationNumber || '').trim().toLowerCase()
|
||||
const name = (data.name || '').trim().toLowerCase()
|
||||
return `${reg}|${name}`
|
||||
}
|
||||
|
||||
export async function migrateLegacyYachtsToPoolIfNeeded(): Promise<void> {
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId || localStorage.getItem(MIGRATION_FLAG) === userId) return
|
||||
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) return
|
||||
|
||||
try {
|
||||
const ownedLogbooks = await db.logbooks.filter((lb) => lb.isShared !== 1).toArray()
|
||||
const poolByKey = new Map<string, string>()
|
||||
const poolData = new Map<string, VesselData>()
|
||||
|
||||
for (const logbook of ownedLogbooks) {
|
||||
const logbookKey = (await getLogbookKey(logbook.id)) || masterKey
|
||||
const legacyYacht = await db.yachts.get(logbook.id)
|
||||
if (!legacyYacht) continue
|
||||
|
||||
const data = (await decryptJson(
|
||||
legacyYacht.encryptedData,
|
||||
legacyYacht.iv,
|
||||
legacyYacht.tag,
|
||||
logbookKey
|
||||
)) as VesselData | null
|
||||
if (!data?.name?.trim()) continue
|
||||
|
||||
const key = dedupeKey(data)
|
||||
let poolId = poolByKey.get(key)
|
||||
if (!poolId) {
|
||||
poolId = crypto.randomUUID()
|
||||
const existing = await db.vesselPool.get(poolId)
|
||||
if (!existing) {
|
||||
const encrypted = await encryptJson(data, masterKey)
|
||||
const now = new Date().toISOString()
|
||||
await db.vesselPool.put({
|
||||
payloadId: poolId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
await db.userSyncQueue.put({
|
||||
action: 'create',
|
||||
type: 'vessel',
|
||||
payloadId: poolId,
|
||||
data: JSON.stringify(encrypted),
|
||||
updatedAt: now
|
||||
})
|
||||
}
|
||||
poolByKey.set(key, poolId)
|
||||
poolData.set(poolId, data)
|
||||
}
|
||||
|
||||
const existingSelection = await db.logbookVesselSelections.get(logbook.id)
|
||||
if (!existingSelection) {
|
||||
const selection = buildLogbookVesselSelection(poolId, poolData)
|
||||
await saveLogbookVesselSelection(logbook.id, selection)
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem(MIGRATION_FLAG, userId)
|
||||
} catch (err) {
|
||||
console.warn('Vessel pool migration failed:', err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { decryptJson, encryptJson } from './crypto.js'
|
||||
import type { VesselData } from '../types/vessel.js'
|
||||
import { MAX_POOL_VESSELS } from '../types/vessel.js'
|
||||
import { syncVesselPool } from './vesselPoolSync.js'
|
||||
|
||||
export interface DecryptedVessel {
|
||||
payloadId: string
|
||||
data: VesselData
|
||||
}
|
||||
|
||||
function requireMasterKey(): ArrayBuffer {
|
||||
const key = getActiveMasterKey()
|
||||
if (!key) throw new Error('Encryption key not found. Please log in.')
|
||||
return key
|
||||
}
|
||||
|
||||
export async function loadVesselPool(): Promise<DecryptedVessel[]> {
|
||||
const masterKey = requireMasterKey()
|
||||
const records = await db.vesselPool.toArray()
|
||||
const result: DecryptedVessel[] = []
|
||||
|
||||
for (const record of records) {
|
||||
const data = (await decryptJson(record.encryptedData, record.iv, record.tag, masterKey)) as
|
||||
| VesselData
|
||||
| null
|
||||
if (data?.name) {
|
||||
result.push({ payloadId: record.payloadId, data })
|
||||
}
|
||||
}
|
||||
|
||||
result.sort((a, b) =>
|
||||
a.data.name.localeCompare(b.data.name, undefined, { sensitivity: 'base' })
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
export async function loadVesselPoolMap(): Promise<Map<string, VesselData>> {
|
||||
const vessels = await loadVesselPool()
|
||||
return new Map(vessels.map((v) => [v.payloadId, v.data]))
|
||||
}
|
||||
|
||||
export async function saveVessel(
|
||||
payloadId: string,
|
||||
data: VesselData,
|
||||
isNew: boolean
|
||||
): Promise<void> {
|
||||
if (isNew) {
|
||||
const count = await db.vesselPool.count()
|
||||
if (count >= MAX_POOL_VESSELS) {
|
||||
throw new Error('MAX_VESSELS')
|
||||
}
|
||||
}
|
||||
|
||||
const masterKey = requireMasterKey()
|
||||
const encrypted = await encryptJson(data, masterKey)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
await db.vesselPool.put({
|
||||
payloadId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
await db.userSyncQueue.put({
|
||||
action: isNew ? 'create' : 'update',
|
||||
type: 'vessel',
|
||||
payloadId,
|
||||
data: JSON.stringify(encrypted),
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
syncVesselPool().catch((err) => console.warn('Vessel pool sync failed:', err))
|
||||
}
|
||||
|
||||
export async function deleteVessel(payloadId: string): Promise<void> {
|
||||
const now = new Date().toISOString()
|
||||
await db.vesselPool.delete(payloadId)
|
||||
await db.userSyncQueue.put({
|
||||
action: 'delete',
|
||||
type: 'vessel',
|
||||
payloadId,
|
||||
data: '',
|
||||
updatedAt: now
|
||||
})
|
||||
syncVesselPool().catch((err) => console.warn('Vessel pool sync failed:', err))
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { apiFetch } from './api.js'
|
||||
|
||||
const API_BASE = '/api/auth/vessel-pool'
|
||||
|
||||
function isNewer(timeA: string | Date, timeB: string | Date): boolean {
|
||||
return new Date(timeA).getTime() > new Date(timeB).getTime()
|
||||
}
|
||||
|
||||
export async function syncVesselPool(): Promise<void> {
|
||||
if (!navigator.onLine || !getActiveMasterKey() || !localStorage.getItem('active_userid')) return
|
||||
|
||||
await pushVesselPool()
|
||||
await pullVesselPool()
|
||||
}
|
||||
|
||||
async function pushVesselPool(): Promise<void> {
|
||||
const pending = (await db.userSyncQueue.toArray()).filter((item) => item.type === 'vessel')
|
||||
if (pending.length === 0) return
|
||||
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/push`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ items: pending })
|
||||
})
|
||||
if (!response.ok) {
|
||||
console.warn('Vessel pool push rejected')
|
||||
return
|
||||
}
|
||||
|
||||
const { results } = await response.json()
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const res = results[i]
|
||||
const item = pending[i]
|
||||
if (!item) continue
|
||||
if (res.status === 'success' && item.id !== undefined) {
|
||||
await db.userSyncQueue.delete(item.id)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Vessel pool push failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function pullVesselPool(): Promise<void> {
|
||||
try {
|
||||
const response = await apiFetch(API_BASE, { method: 'GET' })
|
||||
if (!response.ok) return
|
||||
|
||||
const { vessels } = await response.json()
|
||||
if (!Array.isArray(vessels)) return
|
||||
|
||||
const serverMap = new Map<string, (typeof vessels)[0]>()
|
||||
for (const v of vessels) {
|
||||
serverMap.set(v.payloadId, v)
|
||||
const local = await db.vesselPool.get(v.payloadId)
|
||||
if (!local || isNewer(v.updatedAt, local.updatedAt)) {
|
||||
await db.vesselPool.put({
|
||||
payloadId: v.payloadId,
|
||||
encryptedData: v.encryptedData,
|
||||
iv: v.iv,
|
||||
tag: v.tag,
|
||||
updatedAt: v.updatedAt
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const localAll = await db.vesselPool.toArray()
|
||||
for (const local of localAll) {
|
||||
if (!serverMap.has(local.payloadId)) {
|
||||
const pendingCreate = await db.userSyncQueue
|
||||
.where({ payloadId: local.payloadId, action: 'create' })
|
||||
.first()
|
||||
if (!pendingCreate) {
|
||||
await db.vesselPool.delete(local.payloadId)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Vessel pool pull failed:', err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PlausibleEvents } from './analytics.js'
|
||||
|
||||
const apiFetch = vi.fn()
|
||||
const trackPlausibleEvent = vi.fn()
|
||||
|
||||
vi.mock('./api.js', () => ({ apiFetch }))
|
||||
vi.mock('./analytics.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./analytics.js')>()
|
||||
return {
|
||||
...actual,
|
||||
trackPlausibleEvent: (...args: unknown[]) => trackPlausibleEvent(...args)
|
||||
}
|
||||
})
|
||||
vi.mock('./userPreferences.js', () => ({
|
||||
getOwmApiKeyForActiveUser: () => ''
|
||||
}))
|
||||
|
||||
describe('fetchOpenWeatherCurrent', () => {
|
||||
beforeEach(() => {
|
||||
apiFetch.mockReset()
|
||||
trackPlausibleEvent.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('tracks OWM Weather Fetched on success when analyticsSource is set', async () => {
|
||||
apiFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ coord: { lat: 54, lon: 10 }, main: { temp: 20 } })
|
||||
})
|
||||
|
||||
const { fetchOpenWeatherCurrent } = await import('./weather.js')
|
||||
await fetchOpenWeatherCurrent(
|
||||
{ lat: '54.0', lon: '10.0' },
|
||||
{ analyticsSource: 'live_log' }
|
||||
)
|
||||
|
||||
expect(trackPlausibleEvent).toHaveBeenCalledWith(PlausibleEvents.OWM_WEATHER_FETCHED, {
|
||||
source: 'live_log'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not track when the API request fails', async () => {
|
||||
apiFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({ error: 'fail' })
|
||||
})
|
||||
|
||||
const { fetchOpenWeatherCurrent, WeatherApiError } = await import('./weather.js')
|
||||
await expect(
|
||||
fetchOpenWeatherCurrent({ lat: '54', lon: '10' }, { analyticsSource: 'entry_editor' })
|
||||
).rejects.toBeInstanceOf(WeatherApiError)
|
||||
|
||||
expect(trackPlausibleEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,10 @@
|
||||
import { apiFetch } from './api.js'
|
||||
import { getOwmApiKeyForActiveUser } from './userPreferences.js'
|
||||
import {
|
||||
type OwmAnalyticsSource,
|
||||
PlausibleEvents,
|
||||
trackPlausibleEvent
|
||||
} from './analytics.js'
|
||||
|
||||
export class WeatherApiError extends Error {
|
||||
code: 'NO_KEY' | 'REQUEST_FAILED'
|
||||
@@ -11,11 +16,16 @@ export class WeatherApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchOpenWeatherCurrent(params: {
|
||||
lat?: string
|
||||
lon?: string
|
||||
q?: string
|
||||
}): Promise<Record<string, unknown>> {
|
||||
const OWM_FETCH_TIMEOUT_MS = 20_000
|
||||
|
||||
export async function fetchOpenWeatherCurrent(
|
||||
params: {
|
||||
lat?: string
|
||||
lon?: string
|
||||
q?: string
|
||||
},
|
||||
options?: { analyticsSource: OwmAnalyticsSource }
|
||||
): Promise<Record<string, unknown>> {
|
||||
const searchParams = new URLSearchParams()
|
||||
|
||||
if (params.lat && params.lon) {
|
||||
@@ -31,7 +41,22 @@ export async function fetchOpenWeatherCurrent(params: {
|
||||
const headers: Record<string, string> = {}
|
||||
if (userKey) headers['X-OWM-Api-Key'] = userKey
|
||||
|
||||
const res = await apiFetch(`/api/weather/current?${searchParams.toString()}`, { headers })
|
||||
const controller = new AbortController()
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), OWM_FETCH_TIMEOUT_MS)
|
||||
let res: Response
|
||||
try {
|
||||
res = await apiFetch(`/api/weather/current?${searchParams.toString()}`, {
|
||||
headers,
|
||||
signal: controller.signal
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
throw new WeatherApiError('Weather request timed out')
|
||||
}
|
||||
throw err
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId)
|
||||
}
|
||||
|
||||
if (res.status === 503) {
|
||||
throw new WeatherApiError('No OpenWeatherMap API key configured', 'NO_KEY')
|
||||
@@ -42,5 +67,11 @@ export async function fetchOpenWeatherCurrent(params: {
|
||||
throw new WeatherApiError('Weather API rejected the request')
|
||||
}
|
||||
|
||||
if (options?.analyticsSource) {
|
||||
trackPlausibleEvent(PlausibleEvents.OWM_WEATHER_FETCHED, {
|
||||
source: options.analyticsSource
|
||||
})
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user