Compare commits
127 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fae7b20f90 | |||
| 73e7613a1b | |||
| 6c8aa5af4c | |||
| 9554f4b66e | |||
| 5c77bbfdc3 | |||
| 979b572136 | |||
| f189317dfc | |||
| c54f834311 | |||
| 9d05005bb7 | |||
| 40c4874156 | |||
| 2de0636608 | |||
| 9e7c6f4397 | |||
| 6600ceafce | |||
| d7a497a4a2 | |||
| 4c04086d63 | |||
| 79ce42bec6 | |||
| 72c956162c | |||
| 3080b59dc8 | |||
| d054e42cc0 | |||
| d299fc1d93 | |||
| 6447e95d7d | |||
| 7ec5a1eccc | |||
| 4cf70a3431 | |||
| 6ed8b2a8e7 | |||
| bff00cf0a3 | |||
| 3cab735754 | |||
| 79762a0baf | |||
| 24160b6c5d | |||
| 1326045b25 | |||
| e014e997de | |||
| 1bc449687d | |||
| 35ee705510 | |||
| 9f76c200b0 | |||
| ac627a022f | |||
| 9ae24aa6fb | |||
| 91cf2674f7 | |||
| b7a9df6ae0 | |||
| 7bc3c25ba4 | |||
| e2fa036b9c | |||
| 89f0f52841 | |||
| 6f28ea0b16 | |||
| 975c7a2e40 | |||
| f83d67b527 | |||
| 6c48085904 | |||
| 07de51be22 | |||
| d654aad937 | |||
| dd111ce01f | |||
| 978e132c70 | |||
| 1ecebc5dbb | |||
| caf85ad9eb | |||
| d637fbea16 | |||
| 8e03563f65 | |||
| 3ac4201734 | |||
| 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 |
+20
-1
@@ -1,15 +1,34 @@
|
|||||||
OpenWeatherMapAPIKey=<owm_api_key>
|
OpenWeatherMapAPIKey=<owm_api_key>
|
||||||
|
|
||||||
|
# OpenRouter API (AI travel day summaries — server-side proxy)
|
||||||
|
OpenRouterAPIKey=
|
||||||
|
# Optional model override (default: anthropic/claude-3.5-haiku)
|
||||||
|
# Valid examples: anthropic/claude-3.5-haiku, anthropic/claude-3-haiku, anthropic/claude-haiku-4.5
|
||||||
|
# OpenRouterModel=anthropic/claude-3.5-haiku
|
||||||
|
|
||||||
# DeepL API (for scripts/translate-locales.mjs and scripts/translate-flyer.mjs)
|
# DeepL API (for scripts/translate-locales.mjs and scripts/translate-flyer.mjs)
|
||||||
# Free plan keys use api-free.deepl.com automatically (suffix :fx)
|
# Free plan keys use api-free.deepl.com automatically (suffix :fx)
|
||||||
DeepLAPIKey=
|
DeepLAPIKey=
|
||||||
|
|
||||||
# Passkey configuration (WebAuthn Relying Party ID and Origin)
|
# Passkey configuration (WebAuthn Relying Party ID and Origin)
|
||||||
# For local dev: use localhost (NOT 127.0.0.1 — browsers reject IP addresses for Passkeys)
|
# For local dev: use localhost (NOT 127.0.0.1 — browsers reject IP addresses for Passkeys)
|
||||||
# For production: e.g. kapteins-daagbok.eu and https://kapteins-daagbok.eu
|
# Production (kapteins-daagbok.eu):
|
||||||
|
# RP_ID=kapteins-daagbok.eu
|
||||||
|
# ORIGIN=https://kapteins-daagbok.eu
|
||||||
RP_ID=localhost
|
RP_ID=localhost
|
||||||
# Must match the frontend URL exactly (Vite dev: http://localhost:5173; Docker: http://localhost)
|
# Must match the frontend URL exactly (Vite dev: http://localhost:5173; Docker: http://localhost)
|
||||||
ORIGIN=http://localhost:5173
|
ORIGIN=http://localhost:5173
|
||||||
|
|
||||||
|
# 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)
|
# 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
|
# CORS_ORIGINS=http://localhost:5173
|
||||||
|
|
||||||
|
|||||||
@@ -219,13 +219,19 @@ cd server && npx prisma db push && cd ..
|
|||||||
| Health Check | http://localhost:5000/api/health |
|
| Health Check | http://localhost:5000/api/health |
|
||||||
| Public Demo | http://localhost:5173/demo |
|
| Public Demo | http://localhost:5173/demo |
|
||||||
|
|
||||||
### 5. Tests (Frontend)
|
### 5. Qualität & Tests
|
||||||
|
|
||||||
|
Vor jedem Deploy auf [kapteins-daagbok.eu](https://kapteins-daagbok.eu/) (kein externes CI):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd client && npm test
|
npm run check
|
||||||
|
# oder: ./scripts/predeploy-check.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Vitest-Unit-Tests für Utils, i18n und Services (z. B. Kurswinkel, Benutzereinstellungen).
|
Einzeln: `npm test` (Client + Server) · `npm run build` · optional `npm run lint` (Client, noch nicht in `check`)
|
||||||
|
|
||||||
|
- **Client:** Vitest für Utils, i18n, Services
|
||||||
|
- **Server:** Smoke-Tests (`/api/health`, Auth-Guards) mit Supertest — siehe `server/src/api.smoke.test.ts`
|
||||||
|
|
||||||
## Docker (produktionsnah)
|
## Docker (produktionsnah)
|
||||||
|
|
||||||
@@ -237,11 +243,12 @@ Gesamten Stack lokal bauen und starten:
|
|||||||
|
|
||||||
Frontend: http://localhost · API: http://localhost/api/health · Demo: http://localhost/demo
|
Frontend: http://localhost · API: http://localhost/api/health · Demo: http://localhost/demo
|
||||||
|
|
||||||
Umgebungsvariablen in `.env` setzen — mindestens `RP_ID`, `ORIGIN` (z. B. `http://localhost`) und `SESSION_SECRET`. Für Push die VAPID-Variablen an den Backend-Container durchreichen (`docker-compose.yml` → `backend.environment`). Für Feedback `NTFY_*` setzen.
|
Umgebungsvariablen in `.env` setzen — mindestens `RP_ID`, `ORIGIN` (z. B. `http://localhost`), `SESSION_SECRET` und für Docker Compose `POSTGRES_PASSWORD`. Für Push die VAPID-Variablen an den Backend-Container durchreichen (`docker-compose.yml` → `backend.environment`). Für Feedback `NTFY_*` setzen.
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen):
|
Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen). Führt vor dem SSH-Deploy automatisch [`predeploy-check.sh`](scripts/predeploy-check.sh) aus (`npm run check`):
|
||||||
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/update-prod.sh
|
./scripts/update-prod.sh
|
||||||
@@ -249,12 +256,17 @@ Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen):
|
|||||||
|
|
||||||
Standard-Ziel: `root@10.0.0.25:/opt/kapteins-daagbok` — per `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_DIR` überschreibbar.
|
Standard-Ziel: `root@10.0.0.25:/opt/kapteins-daagbok` — per `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_DIR` überschreibbar.
|
||||||
|
|
||||||
Auf dem Server müssen `server/.env` (oder gleichwertige Umgebung) u. a. `DATABASE_URL`, `RP_ID`, `ORIGIN`, `SESSION_SECRET` (≥ 32 Zeichen) und bei Push `VAPID_*` enthalten. Optional `NTFY_*` für Feedback. Nach Schema-Änderungen: `npx prisma db push` im Backend-Container.
|
Auf dem Server müssen `.env` u. a. `POSTGRES_PASSWORD`, `RP_ID`, `ORIGIN` (`https://kapteins-daagbok.eu`), `SESSION_SECRET` (≥ 32 Zeichen), `TRUST_PROXY` (NPM, z. B. `172.16.10.10` oder `1`) und bei Push `VAPID_*` enthalten. Optional `NTFY_*` für Feedback. Nach Schema-Änderungen: `npx prisma db push` im Backend-Container.
|
||||||
|
|
||||||
|
Hinter **Nginx Proxy Manager**: [docs/deployment/npm-security.md](docs/deployment/npm-security.md).
|
||||||
|
|
||||||
## Dokumentation
|
## Dokumentation
|
||||||
|
|
||||||
| Dokument | Inhalt |
|
| Dokument | Inhalt |
|
||||||
|----------|--------|
|
|----------|--------|
|
||||||
|
| [docs/deployment/npm-security.md](docs/deployment/npm-security.md) | NPM, TLS, `trust proxy`, Security-Header |
|
||||||
|
| [docs/deployment/predeploy.md](docs/deployment/predeploy.md) | Pre-Deploy-Checks ohne CI |
|
||||||
|
| [docs/deployment/postgres-password.md](docs/deployment/postgres-password.md) | PostgreSQL-Passwort rotieren / App-Rolle |
|
||||||
| [docs/plausible-events.md](docs/plausible-events.md) | Custom Events für Plausible Analytics |
|
| [docs/plausible-events.md](docs/plausible-events.md) | Custom Events für Plausible Analytics |
|
||||||
| [docs/push-notifications-plan.md](docs/push-notifications-plan.md) | Web Push: Architektur, API, Testplan |
|
| [docs/push-notifications-plan.md](docs/push-notifications-plan.md) | Web Push: Architektur, API, Testplan |
|
||||||
| [docs/plan-compass-course-dial.md](docs/plan-compass-course-dial.md) | Kompass-Dial: UX- und Implementierungsplan |
|
| [docs/plan-compass-course-dial.md](docs/plan-compass-course-dial.md) | Kompass-Dial: UX- und Implementierungsplan |
|
||||||
|
|||||||
+1
-1
@@ -29,4 +29,4 @@ EXPOSE 80
|
|||||||
|
|
||||||
# Health check to verify Nginx is actively running
|
# Health check to verify Nginx is actively running
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=3s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=3s --retries=3 \
|
||||||
CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1
|
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:80/ || exit 1
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
<meta name="apple-mobile-web-app-title" content="Daagbok" />
|
<meta name="apple-mobile-web-app-title" content="Daagbok" />
|
||||||
<meta name="theme-color" content="#0b0c10" />
|
<meta name="theme-color" content="#0b0c10" />
|
||||||
<script src="/appearance-bootstrap.js"></script>
|
<script src="/appearance-bootstrap.js"></script>
|
||||||
|
<script src="/bootstrap-watchdog.js"></script>
|
||||||
<link rel="apple-touch-icon" href="/logo.png" />
|
<link rel="apple-touch-icon" href="/logo.png" />
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:site_name" content="Kapteins Daagbok" />
|
<meta property="og:site_name" content="Kapteins Daagbok" />
|
||||||
|
|||||||
+22
-2
@@ -3,15 +3,32 @@ server {
|
|||||||
server_name localhost;
|
server_name localhost;
|
||||||
client_max_body_size 50M;
|
client_max_body_size 50M;
|
||||||
|
|
||||||
|
# Security headers (TLS/HSTS at NPM — see docs/deployment/npm-security.md)
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" 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; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
|
||||||
|
|
||||||
# Service worker and app shell must revalidate so PWA updates are detected
|
# Service worker and app shell must revalidate so PWA updates are detected
|
||||||
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ {
|
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" 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; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
|
||||||
}
|
}
|
||||||
|
|
||||||
location = /index.html {
|
location = /index.html {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
add_header Cache-Control "no-cache, must-revalidate";
|
add_header Cache-Control "no-cache, must-revalidate" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" 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; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
|
||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
@@ -26,6 +43,9 @@ server {
|
|||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection 'upgrade';
|
proxy_set_header Connection 'upgrade';
|
||||||
proxy_set_header Host $host;
|
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;
|
proxy_cache_bypass $http_upgrade;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1
@@ -12,6 +12,7 @@
|
|||||||
"bip39": "^3.1.0",
|
"bip39": "^3.1.0",
|
||||||
"dexie": "^4.4.2",
|
"dexie": "^4.4.2",
|
||||||
"dexie-react-hooks": "^4.4.0",
|
"dexie-react-hooks": "^4.4.0",
|
||||||
|
"fflate": "^0.8.3",
|
||||||
"i18next": "^26.3.0",
|
"i18next": "^26.3.0",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
|
|||||||
+3
-2
@@ -22,15 +22,16 @@
|
|||||||
"bip39": "^3.1.0",
|
"bip39": "^3.1.0",
|
||||||
"dexie": "^4.4.2",
|
"dexie": "^4.4.2",
|
||||||
"dexie-react-hooks": "^4.4.0",
|
"dexie-react-hooks": "^4.4.0",
|
||||||
|
"fflate": "^0.8.3",
|
||||||
"i18next": "^26.3.0",
|
"i18next": "^26.3.0",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^1.16.0",
|
"lucide-react": "^1.16.0",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-i18next": "^17.0.8",
|
"react-i18next": "^17.0.8"
|
||||||
"qrcode": "^1.5.4"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
|||||||
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()
|
||||||
|
})()
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 292 KiB |
+694
-67
File diff suppressed because it is too large
Load Diff
+119
-22
@@ -3,8 +3,12 @@ import { DialogProvider } from './components/ModalDialog.tsx'
|
|||||||
import AuthOnboarding from './components/AuthOnboarding.tsx'
|
import AuthOnboarding from './components/AuthOnboarding.tsx'
|
||||||
import UserProfilePage from './components/UserProfilePage.tsx'
|
import UserProfilePage from './components/UserProfilePage.tsx'
|
||||||
import LogbookDashboard from './components/LogbookDashboard.tsx'
|
import LogbookDashboard from './components/LogbookDashboard.tsx'
|
||||||
import VesselForm from './components/VesselForm.tsx'
|
import LogbookVesselPicker from './components/LogbookVesselPicker.tsx'
|
||||||
import CrewForm from './components/CrewForm.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)
|
// Compass Deviation Table — für Freizeit-Skipper vorerst deaktiviert (Komponente bleibt erhalten)
|
||||||
// import DeviationForm from './components/DeviationForm.tsx'
|
// import DeviationForm from './components/DeviationForm.tsx'
|
||||||
import LogEntriesList from './components/LogEntriesList.tsx'
|
import LogEntriesList from './components/LogEntriesList.tsx'
|
||||||
@@ -44,6 +48,7 @@ import { useLiveQuery } from 'dexie-react-hooks'
|
|||||||
import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, Languages, BarChart2 } from 'lucide-react'
|
import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, Languages, BarChart2 } from 'lucide-react'
|
||||||
import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
|
import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
|
||||||
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
|
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
|
||||||
|
import ProfileHeaderButton from './components/ProfileHeaderButton.tsx'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { cycleAppLanguage } from './utils/i18nLanguages.js'
|
import { cycleAppLanguage } from './utils/i18nLanguages.js'
|
||||||
import {
|
import {
|
||||||
@@ -52,6 +57,8 @@ import {
|
|||||||
} from './services/demoLogbook.js'
|
} from './services/demoLogbook.js'
|
||||||
import { fetchLogbooks, parseCollaborationRole } from './services/logbook.js'
|
import { fetchLogbooks, parseCollaborationRole } from './services/logbook.js'
|
||||||
import { ensurePushSubscriptionIfEnabled } from './services/pushNotifications.js'
|
import { ensurePushSubscriptionIfEnabled } from './services/pushNotifications.js'
|
||||||
|
import SyncConflictBanner from './components/SyncConflictBanner.tsx'
|
||||||
|
import { requestPersistentStorage } from './utils/storagePersist.js'
|
||||||
|
|
||||||
const PENDING_PUSH_LOGBOOK_KEY = 'pending_push_logbook_id'
|
const PENDING_PUSH_LOGBOOK_KEY = 'pending_push_logbook_id'
|
||||||
|
|
||||||
@@ -70,6 +77,7 @@ function App() {
|
|||||||
const [isSyncing, setIsSyncing] = useState(false)
|
const [isSyncing, setIsSyncing] = useState(false)
|
||||||
const [isAcceptingInvite, setIsAcceptingInvite] = useState(false)
|
const [isAcceptingInvite, setIsAcceptingInvite] = useState(false)
|
||||||
const [showUserProfile, setShowUserProfile] = useState(false)
|
const [showUserProfile, setShowUserProfile] = useState(false)
|
||||||
|
const [storagePersistHint, setStoragePersistHint] = useState(false)
|
||||||
const tourLogbookRef = useRef<{ id: string; title: string } | null>(null)
|
const tourLogbookRef = useRef<{ id: string; title: string } | null>(null)
|
||||||
const activeLogbookRef = useRef<{ id: string | null; title: string | null }>({
|
const activeLogbookRef = useRef<{ id: string | null; title: string | null }>({
|
||||||
id: activeLogbookId,
|
id: activeLogbookId,
|
||||||
@@ -95,7 +103,7 @@ function App() {
|
|||||||
[activeLogbookId]
|
[activeLogbookId]
|
||||||
)
|
)
|
||||||
|
|
||||||
const [activeAccessRole, setActiveAccessRole] = useState<LogbookAccessRole | null>('OWNER')
|
const [activeAccessRole, setActiveAccessRole] = useState<LogbookAccessRole | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeLogbookId) {
|
if (!activeLogbookId) {
|
||||||
@@ -157,6 +165,8 @@ function App() {
|
|||||||
const userId = localStorage.getItem('active_userid')
|
const userId = localStorage.getItem('active_userid')
|
||||||
if (!userId) return
|
if (!userId) return
|
||||||
void syncAppearancePrefs(userId)
|
void syncAppearancePrefs(userId)
|
||||||
|
void migrateLegacyCrewToPoolIfNeeded().then(() => syncPersonPool())
|
||||||
|
void migrateLegacyYachtsToPoolIfNeeded().then(() => syncVesselPool())
|
||||||
}, [isAuthenticated])
|
}, [isAuthenticated])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -427,10 +437,19 @@ function App() {
|
|||||||
return () => navigator.serviceWorker.removeEventListener('message', onSwMessage)
|
return () => navigator.serviceWorker.removeEventListener('message', onSwMessage)
|
||||||
}, [isAuthenticated, openLogbookById])
|
}, [isAuthenticated, openLogbookById])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) return
|
||||||
|
if (sessionStorage.getItem('storage_persist_hint_dismissed')) return
|
||||||
|
void requestPersistentStorage().then(({ persisted, supported }) => {
|
||||||
|
if (supported && !persisted) setStoragePersistHint(true)
|
||||||
|
})
|
||||||
|
}, [isAuthenticated])
|
||||||
|
|
||||||
const handleAuthenticated = async () => {
|
const handleAuthenticated = async () => {
|
||||||
setIsAuthenticated(true)
|
setIsAuthenticated(true)
|
||||||
trackPlausibleEvent(PlausibleEvents.LOGGED_IN)
|
trackPlausibleEvent(PlausibleEvents.LOGGED_IN)
|
||||||
void ensurePushSubscriptionIfEnabled()
|
void ensurePushSubscriptionIfEnabled()
|
||||||
|
void requestPersistentStorage()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const demo = await seedDemoLogbookIfNeeded()
|
const demo = await seedDemoLogbookIfNeeded()
|
||||||
@@ -555,24 +574,30 @@ function App() {
|
|||||||
const logbookReadOnly =
|
const logbookReadOnly =
|
||||||
activeLogbookRecord?.isShared === 1 && activeAccessRole === 'READ'
|
activeLogbookRecord?.isShared === 1 && activeAccessRole === 'READ'
|
||||||
const isLogbookOwner =
|
const isLogbookOwner =
|
||||||
activeAccessRole === 'OWNER' || activeLogbookRecord?.isShared !== 1
|
activeAccessRole === 'OWNER' ||
|
||||||
|
(activeLogbookRecord != null && activeLogbookRecord.isShared !== 1)
|
||||||
|
|
||||||
|
if (showUserProfile) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'contents' }}>
|
||||||
|
{pwaInstallBanner}
|
||||||
|
<UserProfilePage
|
||||||
|
onBack={() => setShowUserProfile(false)}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (!activeLogbookId) {
|
if (!activeLogbookId) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'contents' }}>
|
<div style={{ display: 'contents' }}>
|
||||||
{pwaInstallBanner}
|
{pwaInstallBanner}
|
||||||
{showUserProfile ? (
|
<LogbookDashboard
|
||||||
<UserProfilePage
|
onSelectLogbook={selectLogbook}
|
||||||
onBack={() => setShowUserProfile(false)}
|
onLogout={handleLogout}
|
||||||
onLogout={handleLogout}
|
onOpenProfile={() => setShowUserProfile(true)}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<LogbookDashboard
|
|
||||||
onSelectLogbook={selectLogbook}
|
|
||||||
onLogout={handleLogout}
|
|
||||||
onOpenProfile={() => setShowUserProfile(true)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -600,7 +625,7 @@ function App() {
|
|||||||
<p className="app-subtitle">
|
<p className="app-subtitle">
|
||||||
{activeAccessRole && activeAccessRole !== 'OWNER'
|
{activeAccessRole && activeAccessRole !== 'OWNER'
|
||||||
? t('dashboard.section_shared_hint')
|
? t('dashboard.section_shared_hint')
|
||||||
: `${t('app.name')} / ${activeLogbookId?.substring(0, 8)}...`}
|
: t('app.tagline')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -622,6 +647,8 @@ function App() {
|
|||||||
<Languages size={18} />
|
<Languages size={18} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<ProfileHeaderButton onClick={() => setShowUserProfile(true)} />
|
||||||
|
|
||||||
<DisclaimerHeaderButton />
|
<DisclaimerHeaderButton />
|
||||||
|
|
||||||
<FeedbackHeaderButton
|
<FeedbackHeaderButton
|
||||||
@@ -638,10 +665,28 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<SyncConflictBanner logbookId={activeLogbookId} />
|
||||||
|
|
||||||
|
{storagePersistHint && (
|
||||||
|
<div className="storage-persist-hint glass" role="status">
|
||||||
|
<p>{t('pwa.storage_persist_hint')}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={() => {
|
||||||
|
sessionStorage.setItem('storage_persist_hint_dismissed', '1')
|
||||||
|
setStoragePersistHint(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('pwa.later')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Active Workspace */}
|
{/* Active Workspace */}
|
||||||
<div className="app-body">
|
<div className="app-body">
|
||||||
{/* Navigation Sidebar */}
|
{/* Navigation Sidebar */}
|
||||||
<aside className="app-sidebar">
|
<aside className="app-sidebar" aria-label={t('nav.dashboard')}>
|
||||||
<button
|
<button
|
||||||
className={`sidebar-btn ${activeTab === 'logs' ? 'active' : ''}`}
|
className={`sidebar-btn ${activeTab === 'logs' ? 'active' : ''}`}
|
||||||
onClick={() => void handleTabChange('logs')}
|
onClick={() => void handleTabChange('logs')}
|
||||||
@@ -663,7 +708,7 @@ function App() {
|
|||||||
<button
|
<button
|
||||||
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
|
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
|
||||||
onClick={() => void handleTabChange('crew')}
|
onClick={() => void handleTabChange('crew')}
|
||||||
data-tour="nav-crew"
|
data-tour="nav-logbook-crew"
|
||||||
>
|
>
|
||||||
<Users size={18} />
|
<Users size={18} />
|
||||||
{t('nav.crew')}
|
{t('nav.crew')}
|
||||||
@@ -710,14 +755,19 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'vessel' && (
|
{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' && (
|
{activeTab === 'crew' && (
|
||||||
<CrewForm
|
<LogbookCrewPicker
|
||||||
logbookId={activeLogbookId}
|
logbookId={activeLogbookId}
|
||||||
readOnly={logbookReadOnly}
|
readOnly={logbookReadOnly}
|
||||||
skipperReadOnly={!isLogbookOwner}
|
selectionOnly={!isLogbookOwner && activeLogbookRecord?.isShared === 1}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -738,6 +788,53 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<nav className="app-bottom-nav" aria-label={t('nav.dashboard')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`bottom-nav-btn ${activeTab === 'logs' ? 'active' : ''}`}
|
||||||
|
onClick={() => void handleTabChange('logs')}
|
||||||
|
data-tour="nav-logs"
|
||||||
|
>
|
||||||
|
<FileText size={20} />
|
||||||
|
<span>{t('nav.logs')}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`bottom-nav-btn ${activeTab === 'vessel' ? 'active' : ''}`}
|
||||||
|
onClick={() => void handleTabChange('vessel')}
|
||||||
|
data-tour="nav-vessel"
|
||||||
|
>
|
||||||
|
<Ship size={20} />
|
||||||
|
<span>{t('nav.vessel')}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`bottom-nav-btn ${activeTab === 'crew' ? 'active' : ''}`}
|
||||||
|
onClick={() => void handleTabChange('crew')}
|
||||||
|
data-tour="nav-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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
|
import { Coffee } from 'lucide-react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
|
|
||||||
const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'
|
const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'
|
||||||
|
const KOFI_URL = 'https://ko-fi.com/kapteinsdaagbok'
|
||||||
|
|
||||||
export default function AppFooter() {
|
export default function AppFooter() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="app-version-footer">
|
<footer className="app-version-footer">
|
||||||
<span className="app-version-footer__version">v{APP_VERSION}</span>
|
<span className="app-version-footer__version">v{APP_VERSION}</span>
|
||||||
@@ -18,6 +23,21 @@ export default function AppFooter() {
|
|||||||
Markus F.J. Busche
|
Markus F.J. Busche
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
|
<span className="app-version-footer__sep" aria-hidden="true">
|
||||||
|
·
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
className="kofi-footer-badge"
|
||||||
|
href={KOFI_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title={t('footer.kofi_title')}
|
||||||
|
aria-label={t('footer.kofi_title')}
|
||||||
|
onClick={() => trackPlausibleEvent(PlausibleEvents.KOFI_LINK_CLICKED)}
|
||||||
|
>
|
||||||
|
<Coffee size={14} aria-hidden="true" />
|
||||||
|
<span>{t('footer.kofi_label')}</span>
|
||||||
|
</a>
|
||||||
</footer>
|
</footer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
interface PersonSnapshot {
|
||||||
|
name: string
|
||||||
|
photo?: string | null
|
||||||
|
role?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreatorAvatarProps {
|
||||||
|
creatorId?: string
|
||||||
|
crewSnapshotsById?: Record<string, PersonSnapshot>
|
||||||
|
fallbackName?: string
|
||||||
|
size?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const colors = [
|
||||||
|
'#2563eb', // blue
|
||||||
|
'#059669', // emerald
|
||||||
|
'#d97706', // amber
|
||||||
|
'#dc2626', // red
|
||||||
|
'#7c3aed', // violet
|
||||||
|
'#db2777', // pink
|
||||||
|
'#0891b2', // cyan
|
||||||
|
'#4f46e5', // indigo
|
||||||
|
'#0f766e', // teal
|
||||||
|
'#9333ea', // purple
|
||||||
|
]
|
||||||
|
|
||||||
|
function getAvatarColor(name: string): string {
|
||||||
|
let hash = 0
|
||||||
|
for (let i = 0; i < name.length; i++) {
|
||||||
|
hash = name.charCodeAt(i) + ((hash << 5) - hash)
|
||||||
|
}
|
||||||
|
const index = Math.abs(hash) % colors.length
|
||||||
|
return colors[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CreatorAvatar({
|
||||||
|
creatorId,
|
||||||
|
crewSnapshotsById,
|
||||||
|
fallbackName,
|
||||||
|
size = 28
|
||||||
|
}: CreatorAvatarProps) {
|
||||||
|
let name = ''
|
||||||
|
let photo: string | null = null
|
||||||
|
let role = ''
|
||||||
|
|
||||||
|
if (creatorId && crewSnapshotsById && crewSnapshotsById[creatorId]) {
|
||||||
|
const snap = crewSnapshotsById[creatorId]
|
||||||
|
name = snap.name || ''
|
||||||
|
photo = snap.photo || null
|
||||||
|
role = snap.role || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to active username if owner or no crew pool matches
|
||||||
|
if (!name) {
|
||||||
|
if (creatorId === 'skipper') {
|
||||||
|
name = fallbackName || localStorage.getItem('active_username') || 'Skipper'
|
||||||
|
role = 'skipper'
|
||||||
|
} else if (fallbackName) {
|
||||||
|
name = fallbackName
|
||||||
|
} else if (creatorId) {
|
||||||
|
// If creatorId is a username itself (fallback from LiveLogView)
|
||||||
|
name = creatorId
|
||||||
|
} else {
|
||||||
|
name = '?'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const initial = name ? name.trim().split(/\s+/)[0]?.charAt(0).toUpperCase() || '?' : '?'
|
||||||
|
const bgColor = name === '?' ? '#64748b' : getAvatarColor(name)
|
||||||
|
|
||||||
|
const style: React.CSSProperties = {
|
||||||
|
width: `${size}px`,
|
||||||
|
height: `${size}px`,
|
||||||
|
borderRadius: '50%',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: `${Math.round(size * 0.45)}px`,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#ffffff',
|
||||||
|
backgroundColor: bgColor,
|
||||||
|
flexShrink: 0,
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.15)',
|
||||||
|
boxSizing: 'border-box'
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleText = role ? (role === 'skipper' ? 'Skipper' : 'Crew') : ''
|
||||||
|
const tooltip = name + (roleText ? ` (${roleText})` : '')
|
||||||
|
|
||||||
|
if (photo) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={photo}
|
||||||
|
alt={name}
|
||||||
|
title={tooltip}
|
||||||
|
style={{
|
||||||
|
...style,
|
||||||
|
objectFit: 'cover',
|
||||||
|
backgroundColor: 'transparent'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={style} title={tooltip} className="creator-avatar-fallback">
|
||||||
|
{initial}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { getLogbookKey } from '../services/logbookKeys.js'
|
|||||||
import { encryptJson, decryptJson } from '../services/crypto.js'
|
import { encryptJson, decryptJson } from '../services/crypto.js'
|
||||||
import { syncLogbook } from '../services/sync.js'
|
import { syncLogbook } from '../services/sync.js'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
|
import { MAX_POOL_CREW_MEMBERS } from '../types/person.js'
|
||||||
import { useDialog } from './ModalDialog.tsx'
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
import { Users, User, Plus, Trash2, Edit2, Save, X, Check, Camera } from 'lucide-react'
|
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" />
|
<Users size={24} className="form-icon" />
|
||||||
<h2>{t('crew.crew_section')}</h2>
|
<h2>{t('crew.crew_section')}</h2>
|
||||||
</div>
|
</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' }}>
|
<button className="btn primary" onClick={openAddMember} style={{ width: 'auto', padding: '8px 16px' }}>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
{t('crew.add_crew')}
|
{t('crew.add_crew')}
|
||||||
@@ -817,7 +818,7 @@ export default function CrewForm({
|
|||||||
<button className="btn-icon" onClick={() => openEditMember(m)} title="Edit">
|
<button className="btn-icon" onClick={() => openEditMember(m)} title="Edit">
|
||||||
<Edit2 size={14} />
|
<Edit2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
<button className="btn-icon logout" onClick={() => handleDeleteMember(m.payloadId)} title="Delete">
|
<button className="btn-icon danger" onClick={() => handleDeleteMember(m.payloadId)} title="Delete">
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
|
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
|
||||||
import VesselForm from './VesselForm.tsx'
|
import LogbookVesselPicker from './LogbookVesselPicker.tsx'
|
||||||
import CrewForm from './CrewForm.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 LogEntriesList from './LogEntriesList.tsx'
|
||||||
import { Ship, Users, FileText, Lock, Globe, ChevronLeft, UserPlus } from 'lucide-react'
|
import { Ship, Users, FileText, Lock, Globe, ChevronLeft, UserPlus } from 'lucide-react'
|
||||||
import { buildPublicDemoFixture, type PublicDemoFixture } from '../services/demoLogbookData.js'
|
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 { useAppTour, type AppTab } from '../context/AppTourContext.tsx'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
|
|
||||||
@@ -52,7 +56,29 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
|||||||
cycleAppLanguage(i18n)
|
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 (
|
return (
|
||||||
<div className="app-layout">
|
<div className="app-layout">
|
||||||
@@ -115,7 +141,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
|||||||
<button
|
<button
|
||||||
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
|
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
|
||||||
onClick={() => setActiveTab('crew')}
|
onClick={() => setActiveTab('crew')}
|
||||||
data-tour="nav-crew"
|
data-tour="nav-logbook-crew"
|
||||||
>
|
>
|
||||||
<Users size={18} />
|
<Users size={18} />
|
||||||
{t('nav.crew')}
|
{t('nav.crew')}
|
||||||
@@ -130,6 +156,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
|||||||
preloadedYacht={yacht}
|
preloadedYacht={yacht}
|
||||||
preloadedEntries={entries}
|
preloadedEntries={entries}
|
||||||
preloadedPhotos={photos}
|
preloadedPhotos={photos}
|
||||||
|
preloadedVoiceMemos={[]}
|
||||||
preloadedGpsTracks={gpsTracks}
|
preloadedGpsTracks={gpsTracks}
|
||||||
controlledSelectedEntryId={tourSelectedEntryId}
|
controlledSelectedEntryId={tourSelectedEntryId}
|
||||||
onSelectedEntryIdChange={setTourSelectedEntryId}
|
onSelectedEntryIdChange={setTourSelectedEntryId}
|
||||||
@@ -138,11 +165,24 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'vessel' && (
|
{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' && (
|
{activeTab === 'crew' && (
|
||||||
<CrewForm logbookId="demo" readOnly={true} preloadedData={crews} />
|
<LogbookCrewPicker
|
||||||
|
logbookId="demo"
|
||||||
|
readOnly={true}
|
||||||
|
preloadedPool={personPool}
|
||||||
|
preloadedSelection={demoSelection}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { getLogbookKey } from '../services/logbookKeys.js'
|
|||||||
import { encryptJson, decryptJson } from '../services/crypto.js'
|
import { encryptJson, decryptJson } from '../services/crypto.js'
|
||||||
import { syncLogbook } from '../services/sync.js'
|
import { syncLogbook } from '../services/sync.js'
|
||||||
import { Compass, Save, Check } from 'lucide-react'
|
import { Compass, Save, Check } from 'lucide-react'
|
||||||
|
import { parseAppDecimalOrZero } from '../utils/numberFormat.js'
|
||||||
|
|
||||||
interface DeviationFormProps {
|
interface DeviationFormProps {
|
||||||
logbookId: string
|
logbookId: string
|
||||||
@@ -97,8 +98,8 @@ export default function DeviationForm({ logbookId, readOnly = false, preloadedDa
|
|||||||
const sanitizedDeviations: Record<number, number> = {}
|
const sanitizedDeviations: Record<number, number> = {}
|
||||||
headings.forEach((h) => {
|
headings.forEach((h) => {
|
||||||
const val = deviations[h] || ''
|
const val = deviations[h] || ''
|
||||||
const parsed = parseFloat(val.replace('+', '').trim())
|
const parsed = parseAppDecimalOrZero(val.replace('+', '').trim())
|
||||||
sanitizedDeviations[h] = isNaN(parsed) ? 0 : parsed
|
sanitizedDeviations[h] = parsed
|
||||||
})
|
})
|
||||||
|
|
||||||
const dataToSave = {
|
const dataToSave = {
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ export default function DisclaimerModal({ open, onClose }: DisclaimerModalProps)
|
|||||||
if (event.key === 'Escape') onClose()
|
if (event.key === 'Escape') onClose()
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', onKeyDown)
|
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])
|
}, [open, onClose])
|
||||||
|
|
||||||
if (!open) return null
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import type { LogEventPayload } from '../utils/logEntryPayload.js'
|
||||||
|
import { parseLiveVoiceRemark } from '../utils/liveEventCodes.js'
|
||||||
|
import { formatEventSummary } from '../utils/formatEventSummary.js'
|
||||||
|
import VoiceMemoPlayer, { type PreloadedVoiceMemo } from './VoiceMemoPlayer.tsx'
|
||||||
|
|
||||||
|
interface EventRemarksCellProps {
|
||||||
|
event: LogEventPayload
|
||||||
|
logbookId: string
|
||||||
|
voiceMemoLookup?: Map<string, PreloadedVoiceMemo>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EventRemarksCell({
|
||||||
|
event,
|
||||||
|
logbookId,
|
||||||
|
voiceMemoLookup
|
||||||
|
}: EventRemarksCellProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const voiceId = parseLiveVoiceRemark(event.remarks.trim())
|
||||||
|
const preloaded = voiceId ? voiceMemoLookup?.get(voiceId) : undefined
|
||||||
|
|
||||||
|
let summary = formatEventSummary(event, t)
|
||||||
|
if (voiceId && preloaded?.caption) {
|
||||||
|
summary = t('logs.live_voice_entry', { caption: preloaded.caption })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`event-remarks-cell${voiceId ? ' event-remarks-cell--voice' : ''}`}>
|
||||||
|
<span>{summary}</span>
|
||||||
|
{voiceId && (
|
||||||
|
<VoiceMemoPlayer
|
||||||
|
audioId={voiceId}
|
||||||
|
logbookId={logbookId}
|
||||||
|
preloaded={preloaded}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useId, useMemo } from 'react'
|
import { useId, useMemo } from 'react'
|
||||||
import { joinTimeHHMM, splitTimeHHMM } from '../utils/logEntryPayload.js'
|
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 HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'))
|
||||||
const MINUTES = Array.from({ length: 60 }, (_, 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
|
'aria-label': ariaLabel
|
||||||
}: EventTimeInput24hProps) {
|
}: EventTimeInput24hProps) {
|
||||||
const baseId = useId()
|
const baseId = useId()
|
||||||
|
const useNativePicker = preferNativeCameraPicker()
|
||||||
const { hours, minutes } = useMemo(() => splitTimeHHMM(value), [value])
|
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 (
|
return (
|
||||||
<div className="time-input-24h">
|
<div className="time-input-24h">
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Signal } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
formatGpsAccuracyMeters,
|
||||||
|
gpsQualityI18nKey,
|
||||||
|
type GpsSignalQuality
|
||||||
|
} from '../utils/geolocation.js'
|
||||||
|
|
||||||
|
const SIGNAL_BARS: Record<GpsSignalQuality, number> = {
|
||||||
|
excellent: 4,
|
||||||
|
good: 3,
|
||||||
|
fair: 2,
|
||||||
|
poor: 1,
|
||||||
|
unknown: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GpsSignalHintProps {
|
||||||
|
quality: GpsSignalQuality
|
||||||
|
accuracyM: number | null
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GpsSignalHint({ quality, accuracyM, className = '' }: GpsSignalHintProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const bars = SIGNAL_BARS[quality]
|
||||||
|
const i18nParams = accuracyM != null ? { accuracy: formatGpsAccuracyMeters(accuracyM) } : undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
className={`gps-signal-hint gps-signal-${quality} ${className}`.trim()}
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<span className="gps-signal-hint-label">
|
||||||
|
<Signal size={14} aria-hidden className="gps-signal-icon" />
|
||||||
|
<span className="gps-signal-bars" aria-hidden>
|
||||||
|
{[1, 2, 3, 4].map((level) => (
|
||||||
|
<span
|
||||||
|
key={level}
|
||||||
|
className={`gps-signal-bar ${level <= bars ? 'is-active' : ''}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<span>{t(gpsQualityI18nKey(quality), i18nParams)}</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Camera, X } from 'lucide-react'
|
import { Camera, X } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
cameraErrorKeyFromDomException,
|
||||||
|
probeCameraAvailability
|
||||||
|
} from '../utils/cameraAvailability.js'
|
||||||
|
import {
|
||||||
|
captureVideoFrame,
|
||||||
|
preferNativeCameraPicker
|
||||||
|
} from '../utils/captureVideoFrame.js'
|
||||||
|
|
||||||
interface LiveCameraCaptureProps {
|
interface LiveCameraCaptureProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -11,6 +19,8 @@ interface LiveCameraCaptureProps {
|
|||||||
onCapture: (blob: Blob) => void
|
onCapture: (blob: Blob) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Phase = 'checking' | 'live' | 'preview' | 'native'
|
||||||
|
|
||||||
export default function LiveCameraCapture({
|
export default function LiveCameraCapture({
|
||||||
open,
|
open,
|
||||||
busy = false,
|
busy = false,
|
||||||
@@ -21,9 +31,26 @@ export default function LiveCameraCapture({
|
|||||||
}: LiveCameraCaptureProps) {
|
}: LiveCameraCaptureProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const videoRef = useRef<HTMLVideoElement | null>(null)
|
const videoRef = useRef<HTMLVideoElement | null>(null)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
const streamRef = useRef<MediaStream | null>(null)
|
const streamRef = useRef<MediaStream | null>(null)
|
||||||
|
const previewUrlRef = useRef<string | null>(null)
|
||||||
|
|
||||||
const [cameraError, setCameraError] = useState<string | null>(null)
|
const [cameraError, setCameraError] = useState<string | null>(null)
|
||||||
const [ready, setReady] = useState(false)
|
const [ready, setReady] = useState(false)
|
||||||
|
const [capturing, setCapturing] = useState(false)
|
||||||
|
const [phase, setPhase] = useState<Phase>('checking')
|
||||||
|
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(() => {
|
const stopStream = useCallback(() => {
|
||||||
for (const track of streamRef.current?.getTracks() ?? []) {
|
for (const track of streamRef.current?.getTracks() ?? []) {
|
||||||
@@ -36,10 +63,69 @@ export default function LiveCameraCapture({
|
|||||||
setReady(false)
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
stopStream()
|
stopStream()
|
||||||
|
clearPreview()
|
||||||
setCameraError(null)
|
setCameraError(null)
|
||||||
|
setCapturing(false)
|
||||||
|
setPhase('checking')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
clearPreview()
|
||||||
|
setCameraError(null)
|
||||||
|
setCapturing(false)
|
||||||
|
setPhase('checking')
|
||||||
|
|
||||||
|
const probe = async () => {
|
||||||
|
const availability = await probeCameraAvailability()
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
|
if (availability === 'unsupported') {
|
||||||
|
setCameraError(t('logs.live_photo_camera_unavailable'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (availability === 'none') {
|
||||||
|
setCameraError(t('logs.live_photo_no_camera'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setPhase(preferNativeCameraPicker() ? 'native' : 'live')
|
||||||
|
}
|
||||||
|
|
||||||
|
void probe()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [open, clearPreview, stopStream, t])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || phase !== 'live') {
|
||||||
|
stopStream()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,11 +134,6 @@ export default function LiveCameraCapture({
|
|||||||
const start = async () => {
|
const start = async () => {
|
||||||
setCameraError(null)
|
setCameraError(null)
|
||||||
setReady(false)
|
setReady(false)
|
||||||
if (!navigator.mediaDevices?.getUserMedia) {
|
|
||||||
setCameraError(t('logs.live_photo_camera_unavailable'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
video: {
|
video: {
|
||||||
@@ -68,15 +149,23 @@ export default function LiveCameraCapture({
|
|||||||
}
|
}
|
||||||
streamRef.current = stream
|
streamRef.current = stream
|
||||||
const video = videoRef.current
|
const video = videoRef.current
|
||||||
if (video) {
|
if (!video) return
|
||||||
video.srcObject = stream
|
|
||||||
await video.play()
|
const markReady = () => {
|
||||||
setReady(true)
|
if (cancelled) return
|
||||||
|
if (video.videoWidth > 0 && video.videoHeight > 0) {
|
||||||
|
setReady(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
video.onloadedmetadata = markReady
|
||||||
|
video.srcObject = stream
|
||||||
|
await video.play()
|
||||||
|
markReady()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Camera access failed:', err)
|
console.error('Camera access failed:', err)
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setCameraError(t('logs.live_photo_camera_denied'))
|
setCameraError(t(cameraErrorKeyFromDomException(err)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,34 +175,58 @@ export default function LiveCameraCapture({
|
|||||||
cancelled = true
|
cancelled = true
|
||||||
stopStream()
|
stopStream()
|
||||||
}
|
}
|
||||||
}, [open, stopStream, t])
|
}, [open, phase, streamGeneration, stopStream, t])
|
||||||
|
|
||||||
const handleCapture = () => {
|
const handleCapture = async () => {
|
||||||
const video = videoRef.current
|
const video = videoRef.current
|
||||||
if (!video || !ready || busy) return
|
if (!video || !ready || busy || capturing) return
|
||||||
|
|
||||||
const width = video.videoWidth
|
setCapturing(true)
|
||||||
const height = video.videoHeight
|
setCameraError(null)
|
||||||
if (!width || !height) return
|
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 canvas = document.createElement('canvas')
|
const handleNativeFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
canvas.width = width
|
const file = e.target.files?.[0]
|
||||||
canvas.height = height
|
e.target.value = ''
|
||||||
const ctx = canvas.getContext('2d')
|
if (!file || busy) return
|
||||||
if (!ctx) return
|
|
||||||
ctx.drawImage(video, 0, 0, width, height)
|
|
||||||
|
|
||||||
canvas.toBlob(
|
setCameraError(null)
|
||||||
(blob) => {
|
try {
|
||||||
if (blob) onCapture(blob)
|
enterPreview(file)
|
||||||
},
|
} catch (err) {
|
||||||
'image/jpeg',
|
console.error('Live camera file pick failed:', err)
|
||||||
0.92
|
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
|
if (!open) return null
|
||||||
|
|
||||||
|
const showPreview = phase === 'preview' && previewUrl
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="live-log-modal-backdrop live-camera-backdrop"
|
className="live-log-modal-backdrop live-camera-backdrop"
|
||||||
@@ -127,15 +240,53 @@ export default function LiveCameraCapture({
|
|||||||
className="btn secondary live-camera-close"
|
className="btn secondary live-camera-close"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
aria-label={t('logs.confirm_no')}
|
aria-label={t('logs.live_cancel')}
|
||||||
>
|
>
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{cameraError ? (
|
<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>
|
<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>
|
||||||
|
) : cameraError ? (
|
||||||
|
<div className="live-camera-preview-wrap">
|
||||||
|
<p className="live-camera-loading">{cameraError}</p>
|
||||||
|
</div>
|
||||||
|
) : phase === 'checking' ? (
|
||||||
|
<p className="live-camera-loading">{t('logs.live_photo_camera_starting')}</p>
|
||||||
|
) : 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>
|
||||||
|
) : phase === 'live' ? (
|
||||||
<div className="live-camera-preview-wrap">
|
<div className="live-camera-preview-wrap">
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
@@ -148,7 +299,7 @@ export default function LiveCameraCapture({
|
|||||||
<p className="live-camera-loading">{t('logs.live_photo_camera_starting')}</p>
|
<p className="live-camera-loading">{t('logs.live_photo_camera_starting')}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
{onCaptionChange && (
|
{onCaptionChange && (
|
||||||
<div className="input-group live-camera-caption">
|
<div className="input-group live-camera-caption">
|
||||||
@@ -166,17 +317,35 @@ export default function LiveCameraCapture({
|
|||||||
|
|
||||||
<div className="live-log-modal-actions live-camera-actions">
|
<div className="live-log-modal-actions live-camera-actions">
|
||||||
<button type="button" className="btn secondary" onClick={onClose} disabled={busy}>
|
<button type="button" className="btn secondary" onClick={onClose} disabled={busy}>
|
||||||
{t('logs.confirm_no')}
|
{t('logs.live_cancel')}
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn primary live-camera-shutter"
|
|
||||||
onClick={handleCapture}
|
|
||||||
disabled={busy || !ready || !!cameraError}
|
|
||||||
>
|
|
||||||
<Camera size={18} />
|
|
||||||
{busy ? t('logs.photo_processing') : t('logs.live_photo_capture_btn')}
|
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,379 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Mic, Square, X } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
assertVoiceMemoBlobSize,
|
||||||
|
formatVoiceDuration,
|
||||||
|
pickMediaRecorderMimeType,
|
||||||
|
VOICE_MEMO_MAX_DURATION_SEC
|
||||||
|
} from '../utils/audioBlob.js'
|
||||||
|
|
||||||
|
interface LiveVoiceCaptureProps {
|
||||||
|
open: boolean
|
||||||
|
busy?: boolean
|
||||||
|
caption?: string
|
||||||
|
onCaptionChange?: (value: string) => void
|
||||||
|
onClose: () => void
|
||||||
|
onSave: (blob: Blob, mimeType: string, durationSec: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type Phase = 'idle' | 'recording' | 'preview'
|
||||||
|
|
||||||
|
export default function LiveVoiceCapture({
|
||||||
|
open,
|
||||||
|
busy = false,
|
||||||
|
caption = '',
|
||||||
|
onCaptionChange,
|
||||||
|
onClose,
|
||||||
|
onSave
|
||||||
|
}: LiveVoiceCaptureProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
||||||
|
const streamRef = useRef<MediaStream | null>(null)
|
||||||
|
const chunksRef = useRef<Blob[]>([])
|
||||||
|
const previewUrlRef = useRef<string | null>(null)
|
||||||
|
const startedAtRef = useRef<number>(0)
|
||||||
|
const timerRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
const [phase, setPhase] = useState<Phase>('idle')
|
||||||
|
const [micError, setMicError] = useState<string | null>(null)
|
||||||
|
const [elapsedSec, setElapsedSec] = useState(0)
|
||||||
|
const [previewBlob, setPreviewBlob] = useState<Blob | null>(null)
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||||
|
const [previewMime, setPreviewMime] = useState('audio/webm')
|
||||||
|
const [previewDurationSec, setPreviewDurationSec] = useState(0)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const log = useCallback((msg: string) => {
|
||||||
|
console.log(`[VoiceDebug] ${msg}`)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const previewAudioRef = useRef<HTMLAudioElement | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = previewAudioRef.current
|
||||||
|
if (!el) {
|
||||||
|
log('previewAudioRef is null')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log('Preview audio player loaded. readyState=' + el.readyState + ', duration=' + el.duration + ', src=' + el.src)
|
||||||
|
|
||||||
|
const handleLoadedMetadata = () => {
|
||||||
|
log('loadedmetadata event fired. readyState=' + el.readyState + ', duration=' + el.duration)
|
||||||
|
if (el.duration === Infinity || isNaN(el.duration) || el.duration === 0) {
|
||||||
|
log('Duration correction hack triggered (duration=' + el.duration + '). Seeking to 1e10...')
|
||||||
|
el.currentTime = 1e10
|
||||||
|
const onTimeUpdate = () => {
|
||||||
|
log('timeupdate event. currentTime=' + el.currentTime + ', duration=' + el.duration)
|
||||||
|
el.currentTime = 0
|
||||||
|
el.removeEventListener('timeupdate', onTimeUpdate)
|
||||||
|
log('currentTime reset to 0. Final duration=' + el.duration)
|
||||||
|
}
|
||||||
|
el.addEventListener('timeupdate', onTimeUpdate)
|
||||||
|
} else {
|
||||||
|
log('Duration correction skipped (duration is valid)')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (el.readyState >= 1) {
|
||||||
|
log('readyState >= 1. Executing hack immediately...')
|
||||||
|
handleLoadedMetadata()
|
||||||
|
} else {
|
||||||
|
log('readyState = 0. Adding loadedmetadata event listener...')
|
||||||
|
el.addEventListener('loadedmetadata', handleLoadedMetadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
log('Calling el.load() to force loading of the media resource...')
|
||||||
|
el.load()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener('loadedmetadata', handleLoadedMetadata)
|
||||||
|
}
|
||||||
|
}, [previewUrl, log])
|
||||||
|
|
||||||
|
const stopStream = useCallback(() => {
|
||||||
|
for (const track of streamRef.current?.getTracks() ?? []) {
|
||||||
|
track.stop()
|
||||||
|
}
|
||||||
|
streamRef.current = null
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const clearPreview = useCallback(() => {
|
||||||
|
if (previewUrlRef.current) {
|
||||||
|
URL.revokeObjectURL(previewUrlRef.current)
|
||||||
|
previewUrlRef.current = null
|
||||||
|
}
|
||||||
|
setPreviewUrl(null)
|
||||||
|
setPreviewBlob(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const clearTimer = useCallback(() => {
|
||||||
|
if (timerRef.current != null) {
|
||||||
|
window.clearInterval(timerRef.current)
|
||||||
|
timerRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const resetAll = useCallback(() => {
|
||||||
|
if (mediaRecorderRef.current?.state === 'recording') {
|
||||||
|
mediaRecorderRef.current.stop()
|
||||||
|
}
|
||||||
|
mediaRecorderRef.current = null
|
||||||
|
chunksRef.current = []
|
||||||
|
clearTimer()
|
||||||
|
stopStream()
|
||||||
|
clearPreview()
|
||||||
|
setPhase('idle')
|
||||||
|
setMicError(null)
|
||||||
|
setElapsedSec(0)
|
||||||
|
setSaving(false)
|
||||||
|
}, [stopStream, clearPreview, clearTimer])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
resetAll()
|
||||||
|
}
|
||||||
|
}, [open, resetAll])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
resetAll()
|
||||||
|
}
|
||||||
|
}, [resetAll])
|
||||||
|
|
||||||
|
const finishRecording = useCallback((blob: Blob, mimeType: string, durationSec: number) => {
|
||||||
|
clearPreview()
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
previewUrlRef.current = url
|
||||||
|
setPreviewBlob(blob)
|
||||||
|
setPreviewUrl(url)
|
||||||
|
setPreviewMime(mimeType)
|
||||||
|
setPreviewDurationSec(durationSec)
|
||||||
|
setPhase('preview')
|
||||||
|
}, [clearPreview])
|
||||||
|
|
||||||
|
const stopRecording = useCallback(() => {
|
||||||
|
const recorder = mediaRecorderRef.current
|
||||||
|
if (!recorder || recorder.state !== 'recording') return
|
||||||
|
recorder.stop()
|
||||||
|
clearTimer()
|
||||||
|
}, [clearTimer])
|
||||||
|
|
||||||
|
const startRecording = async () => {
|
||||||
|
setMicError(null)
|
||||||
|
chunksRef.current = []
|
||||||
|
log('startRecording flow triggered')
|
||||||
|
if (!navigator.mediaDevices?.getUserMedia) {
|
||||||
|
log('navigator.mediaDevices.getUserMedia is unavailable')
|
||||||
|
setMicError(t('logs.live_voice_mic_denied'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
log('Requesting getUserMedia audio stream...')
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||||
|
streamRef.current = stream
|
||||||
|
log('Stream obtained successfully. active=' + stream.active)
|
||||||
|
stream.getTracks().forEach((track, i) => {
|
||||||
|
log(`Track ${i}: label="${track.label}" enabled=${track.enabled} readyState=${track.readyState} muted=${track.muted}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
const mimeType = pickMediaRecorderMimeType()
|
||||||
|
log('MIME type candidates support check:')
|
||||||
|
const MIME_CANDIDATES = [
|
||||||
|
'audio/webm;codecs=opus',
|
||||||
|
'audio/webm',
|
||||||
|
'audio/mp4',
|
||||||
|
'audio/ogg;codecs=opus'
|
||||||
|
]
|
||||||
|
MIME_CANDIDATES.forEach(mime => {
|
||||||
|
log(` - ${mime}: ${MediaRecorder.isTypeSupported(mime) ? 'SUPPORTED' : 'UNSUPPORTED'}`)
|
||||||
|
})
|
||||||
|
log('Selected MIME from picker: ' + mimeType)
|
||||||
|
|
||||||
|
const recorder = mimeType
|
||||||
|
? new MediaRecorder(stream, { mimeType })
|
||||||
|
: new MediaRecorder(stream)
|
||||||
|
mediaRecorderRef.current = recorder
|
||||||
|
const resolvedMime = recorder.mimeType || mimeType || 'audio/webm'
|
||||||
|
log('MediaRecorder created. Resolved mime=' + resolvedMime)
|
||||||
|
|
||||||
|
recorder.ondataavailable = (ev) => {
|
||||||
|
log(`ondataavailable event: data size=${ev.data?.size} bytes`)
|
||||||
|
if (ev.data && ev.data.size > 0) {
|
||||||
|
chunksRef.current.push(ev.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recorder.onstop = () => {
|
||||||
|
const durationSec = Math.min(
|
||||||
|
VOICE_MEMO_MAX_DURATION_SEC,
|
||||||
|
Math.max(1, Math.round((Date.now() - startedAtRef.current) / 1000))
|
||||||
|
)
|
||||||
|
log(`onstop triggered. durationSec=${durationSec}. Wrapping in 50ms timeout...`)
|
||||||
|
setTimeout(() => {
|
||||||
|
log(`Creating Blob from ${chunksRef.current.length} chunks. Resolved mime=${resolvedMime}`)
|
||||||
|
const totalChunksSize = chunksRef.current.reduce((acc, chunk) => acc + chunk.size, 0)
|
||||||
|
log(`Total raw chunks size: ${totalChunksSize} bytes`)
|
||||||
|
const blob = new Blob(chunksRef.current, { type: resolvedMime })
|
||||||
|
chunksRef.current = []
|
||||||
|
stopStream()
|
||||||
|
log(`Blob finalized: size=${blob.size} bytes, type=${blob.type}`)
|
||||||
|
try {
|
||||||
|
assertVoiceMemoBlobSize(blob)
|
||||||
|
log('Blob size assertion passed. Calling finishRecording...')
|
||||||
|
finishRecording(blob, resolvedMime, durationSec)
|
||||||
|
} catch (err) {
|
||||||
|
log('Blob size assertion failed (too large)')
|
||||||
|
setMicError(t('logs.live_voice_too_large'))
|
||||||
|
setPhase('idle')
|
||||||
|
}
|
||||||
|
}, 50)
|
||||||
|
}
|
||||||
|
|
||||||
|
recorder.onerror = (ev) => {
|
||||||
|
log('MediaRecorder onerror triggered: ' + JSON.stringify(ev))
|
||||||
|
setMicError(t('logs.live_voice_record_failed'))
|
||||||
|
resetAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
startedAtRef.current = Date.now()
|
||||||
|
log('Calling recorder.start()...')
|
||||||
|
recorder.start()
|
||||||
|
log('recorder.start() called. State=' + recorder.state)
|
||||||
|
setPhase('recording')
|
||||||
|
setElapsedSec(0)
|
||||||
|
timerRef.current = window.setInterval(() => {
|
||||||
|
const sec = Math.floor((Date.now() - startedAtRef.current) / 1000)
|
||||||
|
setElapsedSec(sec)
|
||||||
|
if (sec >= VOICE_MEMO_MAX_DURATION_SEC) {
|
||||||
|
log('Max duration reached. Stopping recording...')
|
||||||
|
stopRecording()
|
||||||
|
}
|
||||||
|
}, 250)
|
||||||
|
} catch (err: any) {
|
||||||
|
log('Error in startRecording try-catch block: ' + (err instanceof Error ? err.stack || err.message : String(err)))
|
||||||
|
setMicError(t('logs.live_voice_mic_denied'))
|
||||||
|
stopStream()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!previewBlob || saving || busy) {
|
||||||
|
log('handleSave ignored. previewBlob=' + (previewBlob ? 'PRESENT' : 'NULL') + ' saving=' + saving + ' busy=' + busy)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log('handleSave triggered. Saving blob size=' + previewBlob.size + ' mime=' + previewMime + ' duration=' + previewDurationSec)
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
log('Invoking onSave callback...')
|
||||||
|
await onSave(previewBlob, previewMime, previewDurationSec)
|
||||||
|
log('onSave callback successfully finished!')
|
||||||
|
} catch (err: any) {
|
||||||
|
log('Error during onSave execution: ' + (err instanceof Error ? err.stack || err.message : String(err)))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="live-log-modal-backdrop"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget && !busy && !saving && phase !== 'recording') onClose()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="live-log-modal live-voice-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="live-voice-modal-header">
|
||||||
|
<h3>{t('logs.live_voice_btn')}</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-icon"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={busy || saving || phase === 'recording'}
|
||||||
|
aria-label={t('logs.live_cancel')}
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{micError && <p className="live-log-modal-hint auth-error">{micError}</p>}
|
||||||
|
|
||||||
|
{phase === 'idle' && (
|
||||||
|
<>
|
||||||
|
<p className="live-log-modal-hint">{t('logs.live_voice_hint')}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn primary live-voice-record-btn"
|
||||||
|
onClick={() => void startRecording()}
|
||||||
|
disabled={busy || saving}
|
||||||
|
>
|
||||||
|
<Mic size={18} />
|
||||||
|
{t('logs.live_voice_record')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === 'recording' && (
|
||||||
|
<>
|
||||||
|
<p className="live-voice-recording-indicator" role="status" aria-live="polite">
|
||||||
|
<span className="live-voice-recording-dot" aria-hidden />
|
||||||
|
{t('logs.live_voice_recording', { time: formatVoiceDuration(elapsedSec) })}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn primary live-voice-stop-btn"
|
||||||
|
onClick={stopRecording}
|
||||||
|
>
|
||||||
|
<Square size={16} fill="currentColor" />
|
||||||
|
{t('logs.live_voice_stop')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === 'preview' && previewUrl && (
|
||||||
|
<>
|
||||||
|
<audio ref={previewAudioRef} className="voice-memo-player" controls src={previewUrl} preload="auto" />
|
||||||
|
{onCaptionChange && (
|
||||||
|
<label className="live-voice-caption-field">
|
||||||
|
<span>{t('logs.live_voice_caption_label')}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input-text"
|
||||||
|
value={caption}
|
||||||
|
onChange={(e) => onCaptionChange(e.target.value)}
|
||||||
|
placeholder={t('logs.live_voice_caption_placeholder')}
|
||||||
|
disabled={busy || saving}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div className="live-log-modal-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={() => {
|
||||||
|
clearPreview()
|
||||||
|
setPhase('idle')
|
||||||
|
}}
|
||||||
|
disabled={busy || saving}
|
||||||
|
>
|
||||||
|
{t('logs.live_voice_retake')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn primary"
|
||||||
|
onClick={() => void handleSave()}
|
||||||
|
disabled={busy || saving}
|
||||||
|
>
|
||||||
|
{saving ? t('logs.live_voice_saving') : t('logs.live_voice_save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,16 +3,25 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { db } from '../services/db.js'
|
import { db } from '../services/db.js'
|
||||||
import { getActiveMasterKey } from '../services/auth.js'
|
import { getActiveMasterKey } from '../services/auth.js'
|
||||||
import { getLogbookKey } from '../services/logbookKeys.js'
|
import { getLogbookKey } from '../services/logbookKeys.js'
|
||||||
import { decryptJson, encryptJson } from '../services/crypto.js'
|
import { encryptJson } from '../services/crypto.js'
|
||||||
import { syncLogbook } from '../services/sync.js'
|
import { syncLogbook } from '../services/sync.js'
|
||||||
import { downloadCsv, shareCsv } from '../services/csvExport.js'
|
import { downloadCsv, shareCsv } from '../services/csvExport.js'
|
||||||
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
|
import { getErrorMessage } from '../utils/errors.js'
|
||||||
|
import { findTodayEntryId, pruneEmptyTodayDuplicates, tryDecryptEntryPayload } from '../services/quickEventLog.js'
|
||||||
|
import { localDateString } from '../utils/logEntryPayload.js'
|
||||||
import LogEntryEditor from './LogEntryEditor.tsx'
|
import LogEntryEditor from './LogEntryEditor.tsx'
|
||||||
import LiveLogView from './LiveLogView.tsx'
|
import LiveLogView from './LiveLogView.tsx'
|
||||||
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
|
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
|
||||||
import { useDialog } from './ModalDialog.tsx'
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
import { getSkipperSignStatus, type SkipperSignStatus } from '../utils/signatures.js'
|
import { getSkipperSignStatus, type SkipperSignStatus } from '../utils/signatures.js'
|
||||||
|
import {
|
||||||
|
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 { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2, Radio, List } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
carryOverFromPreviousDay,
|
carryOverFromPreviousDay,
|
||||||
@@ -31,6 +40,7 @@ interface LogEntriesListProps {
|
|||||||
preloadedYacht?: any
|
preloadedYacht?: any
|
||||||
preloadedEntries?: any[]
|
preloadedEntries?: any[]
|
||||||
preloadedPhotos?: any[]
|
preloadedPhotos?: any[]
|
||||||
|
preloadedVoiceMemos?: import('./VoiceMemoPlayer.tsx').PreloadedVoiceMemo[]
|
||||||
preloadedGpsTracks?: any[]
|
preloadedGpsTracks?: any[]
|
||||||
controlledSelectedEntryId?: string | null
|
controlledSelectedEntryId?: string | null
|
||||||
onSelectedEntryIdChange?: (id: string | null) => void
|
onSelectedEntryIdChange?: (id: string | null) => void
|
||||||
@@ -55,6 +65,7 @@ export default function LogEntriesList({
|
|||||||
preloadedYacht,
|
preloadedYacht,
|
||||||
preloadedEntries,
|
preloadedEntries,
|
||||||
preloadedPhotos,
|
preloadedPhotos,
|
||||||
|
preloadedVoiceMemos,
|
||||||
preloadedGpsTracks,
|
preloadedGpsTracks,
|
||||||
controlledSelectedEntryId,
|
controlledSelectedEntryId,
|
||||||
onSelectedEntryIdChange,
|
onSelectedEntryIdChange,
|
||||||
@@ -113,25 +124,40 @@ export default function LogEntriesList({
|
|||||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||||
|
|
||||||
|
const todayEntryId = await findTodayEntryId(logbookId)
|
||||||
|
if (todayEntryId) {
|
||||||
|
await pruneEmptyTodayDuplicates(logbookId, todayEntryId)
|
||||||
|
}
|
||||||
|
|
||||||
const local = await db.entries.where({ logbookId }).toArray()
|
const local = await db.entries.where({ logbookId }).toArray()
|
||||||
|
|
||||||
const list: DecryptedEntryItem[] = []
|
const list: DecryptedEntryItem[] = []
|
||||||
|
const needsDecrypt: typeof local = []
|
||||||
|
|
||||||
for (const entry of local) {
|
for (const entry of local) {
|
||||||
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
|
const cached = entryListItemFromLocal(entry)
|
||||||
if (decrypted) {
|
if (cached) {
|
||||||
list.push({
|
list.push(cached)
|
||||||
id: entry.payloadId,
|
} else {
|
||||||
date: decrypted.date || '',
|
needsDecrypt.push(entry)
|
||||||
dayOfTravel: decrypted.dayOfTravel || '',
|
|
||||||
departure: decrypted.departure || '',
|
|
||||||
destination: decrypted.destination || '',
|
|
||||||
updatedAt: entry.updatedAt,
|
|
||||||
skipperSignStatus: await getSkipperSignStatus(decrypted as Record<string, unknown>)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await forEachInBatches(needsDecrypt, 8, async (entry) => {
|
||||||
|
const decrypted = await tryDecryptEntryPayload(entry, 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)
|
// Sort chronological descending (by date, or dayOfTravel numerical)
|
||||||
list.sort((a, b) => {
|
list.sort((a, b) => {
|
||||||
const dateCompare = new Date(b.date).getTime() - new Date(a.date).getTime()
|
const dateCompare = new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||||
@@ -142,7 +168,7 @@ export default function LogEntriesList({
|
|||||||
setEntries(list)
|
setEntries(list)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to load log entries:', err)
|
console.error('Failed to load log entries:', err)
|
||||||
setError(err.message || 'Decryption failed. Could not load journal list.')
|
setError(getErrorMessage(err, t('errors.load_failed')))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -176,7 +202,7 @@ export default function LogEntriesList({
|
|||||||
trackPlausibleEvent(PlausibleEvents.CSV_EXPORTED)
|
trackPlausibleEvent(PlausibleEvents.CSV_EXPORTED)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to download CSV:', err)
|
console.error('Failed to download CSV:', err)
|
||||||
setError(err.message || 'Failed to generate CSV export.')
|
setError(getErrorMessage(err, t('errors.export_failed')))
|
||||||
} finally {
|
} finally {
|
||||||
setExporting(false)
|
setExporting(false)
|
||||||
}
|
}
|
||||||
@@ -204,7 +230,7 @@ export default function LogEntriesList({
|
|||||||
setError(t('logs.share_unsupported'))
|
setError(t('logs.share_unsupported'))
|
||||||
} else {
|
} else {
|
||||||
console.error('Failed to share CSV:', err)
|
console.error('Failed to share CSV:', err)
|
||||||
setError(err.message || 'Failed to share CSV export.')
|
setError(getErrorMessage(err, t('errors.export_failed')))
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setExporting(false)
|
setExporting(false)
|
||||||
@@ -225,7 +251,7 @@ export default function LogEntriesList({
|
|||||||
trackPlausibleEvent(PlausibleEvents.PDF_EXPORTED, { scope: 'entry' })
|
trackPlausibleEvent(PlausibleEvents.PDF_EXPORTED, { scope: 'entry' })
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to download PDF:', err)
|
console.error('Failed to download PDF:', err)
|
||||||
setError(err.message || 'Failed to generate PDF export.')
|
setError(getErrorMessage(err, t('errors.export_failed')))
|
||||||
} finally {
|
} finally {
|
||||||
setExporting(false)
|
setExporting(false)
|
||||||
}
|
}
|
||||||
@@ -238,11 +264,17 @@ export default function LogEntriesList({
|
|||||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||||
|
|
||||||
|
const existingTodayId = await findTodayEntryId(logbookId)
|
||||||
|
if (existingTodayId) {
|
||||||
|
setSelectedEntryId(existingTodayId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const localEntries = await db.entries.where({ logbookId }).toArray()
|
const localEntries = await db.entries.where({ logbookId }).toArray()
|
||||||
const decryptedEntries: Array<LogEntryTankSource & TravelDaySortable> = []
|
const decryptedEntries: Array<LogEntryTankSource & TravelDaySortable> = []
|
||||||
|
|
||||||
for (const entry of localEntries) {
|
for (const entry of localEntries) {
|
||||||
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
|
const decrypted = await tryDecryptEntryPayload(entry, masterKey)
|
||||||
if (decrypted) decryptedEntries.push(decrypted as LogEntryTankSource & TravelDaySortable)
|
if (decrypted) decryptedEntries.push(decrypted as LogEntryTankSource & TravelDaySortable)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,7 +306,13 @@ export default function LogEntriesList({
|
|||||||
|
|
||||||
const localId = window.crypto.randomUUID()
|
const localId = window.crypto.randomUUID()
|
||||||
const nowStr = new Date().toISOString()
|
const nowStr = new Date().toISOString()
|
||||||
const todayStr = nowStr.substring(0, 10)
|
const todayStr = localDateString()
|
||||||
|
|
||||||
|
const { loadDefaultEntryCrewForNewDay } = await import('./EntryCrewSection.js')
|
||||||
|
const entryCrew = await loadDefaultEntryCrewForNewDay(
|
||||||
|
logbookId,
|
||||||
|
previousEntry as Record<string, unknown> | null
|
||||||
|
)
|
||||||
|
|
||||||
const initialPayload = {
|
const initialPayload = {
|
||||||
date: todayStr,
|
date: todayStr,
|
||||||
@@ -284,6 +322,9 @@ export default function LogEntriesList({
|
|||||||
freshwater,
|
freshwater,
|
||||||
fuel,
|
fuel,
|
||||||
...(greywaterLevel > 0 ? { greywater: { level: greywaterLevel } } : {}),
|
...(greywaterLevel > 0 ? { greywater: { level: greywaterLevel } } : {}),
|
||||||
|
selectedSkipperId: entryCrew.selectedSkipperId,
|
||||||
|
selectedCrewIds: entryCrew.selectedCrewIds,
|
||||||
|
crewSnapshotsById: entryCrew.crewSnapshotsById,
|
||||||
signSkipper: '',
|
signSkipper: '',
|
||||||
signCrew: '',
|
signCrew: '',
|
||||||
events: []
|
events: []
|
||||||
@@ -292,14 +333,17 @@ export default function LogEntriesList({
|
|||||||
const encrypted = await encryptJson(initialPayload, masterKey)
|
const encrypted = await encryptJson(initialPayload, masterKey)
|
||||||
|
|
||||||
// Save locally
|
// Save locally
|
||||||
await db.entries.put({
|
await putEntryRecord(
|
||||||
payloadId: localId,
|
{
|
||||||
logbookId,
|
payloadId: localId,
|
||||||
encryptedData: encrypted.ciphertext,
|
logbookId,
|
||||||
iv: encrypted.iv,
|
encryptedData: encrypted.ciphertext,
|
||||||
tag: encrypted.tag,
|
iv: encrypted.iv,
|
||||||
updatedAt: nowStr
|
tag: encrypted.tag,
|
||||||
})
|
updatedAt: nowStr
|
||||||
|
},
|
||||||
|
initialPayload
|
||||||
|
)
|
||||||
|
|
||||||
// Queue for background sync
|
// Queue for background sync
|
||||||
await db.syncQueue.put({
|
await db.syncQueue.put({
|
||||||
@@ -317,7 +361,7 @@ export default function LogEntriesList({
|
|||||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to create entry:', err)
|
console.error('Failed to create entry:', err)
|
||||||
setError(err.message || 'Failed to create new log entry.')
|
setError(getErrorMessage(err, t('errors.save_failed')))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -347,7 +391,7 @@ export default function LogEntriesList({
|
|||||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to delete log entry:', err)
|
console.error('Failed to delete log entry:', err)
|
||||||
setError(err.message || 'Failed to delete log entry.')
|
setError(getErrorMessage(err, t('errors.delete_failed')))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -367,6 +411,7 @@ export default function LogEntriesList({
|
|||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
preloadedEntry={preloadedEntries?.find(entry => (entry.payloadId || entry.id) === selectedEntryId)}
|
preloadedEntry={preloadedEntries?.find(entry => (entry.payloadId || entry.id) === selectedEntryId)}
|
||||||
preloadedPhotos={preloadedPhotos}
|
preloadedPhotos={preloadedPhotos}
|
||||||
|
preloadedVoiceMemos={preloadedVoiceMemos}
|
||||||
preloadedTrack={preloadedGpsTracks?.find(track => track.entryId === selectedEntryId)}
|
preloadedTrack={preloadedGpsTracks?.find(track => track.entryId === selectedEntryId)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -380,7 +425,10 @@ export default function LogEntriesList({
|
|||||||
setReturnToLiveAfterEditor(true)
|
setReturnToLiveAfterEditor(true)
|
||||||
setSelectedEntryId(entryId)
|
setSelectedEntryId(entryId)
|
||||||
}}
|
}}
|
||||||
onSwitchToList={() => setViewMode('list')}
|
onSwitchToList={() => {
|
||||||
|
setViewMode('list')
|
||||||
|
void loadEntries()
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -400,7 +448,7 @@ export default function LogEntriesList({
|
|||||||
: entries[0]?.id ?? null
|
: entries[0]?.id ?? null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="form-card">
|
<div className="logs-journal">
|
||||||
<div className="section-title-bar mb-6">
|
<div className="section-title-bar mb-6">
|
||||||
<div className="form-header" style={{ margin: 0 }}>
|
<div className="form-header" style={{ margin: 0 }}>
|
||||||
<Calendar size={24} className="form-icon" />
|
<Calendar size={24} className="form-icon" />
|
||||||
@@ -460,9 +508,19 @@ export default function LogEntriesList({
|
|||||||
key={item.id}
|
key={item.id}
|
||||||
className="logbook-card glass"
|
className="logbook-card glass"
|
||||||
data-tour={tourFirstEntryId === item.id ? 'entry-first' : undefined}
|
data-tour={tourFirstEntryId === item.id ? 'entry-first' : undefined}
|
||||||
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} />
|
<FileText size={24} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -474,7 +532,7 @@ export default function LogEntriesList({
|
|||||||
</h3>
|
</h3>
|
||||||
<div className="card-meta">
|
<div className="card-meta">
|
||||||
<span className="sync-badge synced">
|
<span className="sync-badge synced">
|
||||||
{t('logs.day_of_travel')} {item.dayOfTravel}
|
{t('logs.travel_day_number', { number: item.dayOfTravel })}
|
||||||
</span>
|
</span>
|
||||||
<EntrySkipperSignBadge status={item.skipperSignStatus} />
|
<EntrySkipperSignBadge status={item.skipperSignStatus} />
|
||||||
<span className="date-badge">
|
<span className="date-badge">
|
||||||
@@ -483,6 +541,8 @@ export default function LogEntriesList({
|
|||||||
</div>
|
</div>
|
||||||
</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}>
|
<button className="btn-pdf" onClick={(e) => handleDownloadPdf(item.id, item.date, e)} title={t('logs.export_pdf')} disabled={exporting}>
|
||||||
<Download size={18} />
|
<Download size={18} />
|
||||||
</button>
|
</button>
|
||||||
@@ -492,8 +552,6 @@ export default function LogEntriesList({
|
|||||||
<Trash2 size={18} />
|
<Trash2 size={18} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ChevronRight size={18} style={{ color: '#475569', marginLeft: 'auto' }} />
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,10 +5,12 @@ import { useDialog } from './ModalDialog.tsx'
|
|||||||
import {
|
import {
|
||||||
downloadBackupBlob,
|
downloadBackupBlob,
|
||||||
exportLogbookBackup,
|
exportLogbookBackup,
|
||||||
|
formatBackupBytes,
|
||||||
parseLogbookBackupFile,
|
parseLogbookBackupFile,
|
||||||
previewLogbookBackup,
|
previewLogbookBackup,
|
||||||
restoreLogbookBackup,
|
restoreLogbookBackup,
|
||||||
type LogbookBackupFile,
|
BACKUP_SIZE_CONFIRM_BYTES,
|
||||||
|
type ParsedLogbookBackup,
|
||||||
type LogbookBackupPreview
|
type LogbookBackupPreview
|
||||||
} from '../services/logbookBackup.js'
|
} from '../services/logbookBackup.js'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
@@ -27,6 +29,12 @@ function mapBackupError(code: string, t: (key: string) => string): string {
|
|||||||
return t('settings.backup_not_owner')
|
return t('settings.backup_not_owner')
|
||||||
case 'BACKUP_INVALID_JSON':
|
case 'BACKUP_INVALID_JSON':
|
||||||
return t('settings.backup_invalid_json')
|
return t('settings.backup_invalid_json')
|
||||||
|
case 'BACKUP_INVALID_ARCHIVE':
|
||||||
|
return t('settings.backup_invalid_archive')
|
||||||
|
case 'BACKUP_VERSION_UNSUPPORTED':
|
||||||
|
return t('settings.backup_version_unsupported')
|
||||||
|
case 'BACKUP_WRONG_PASSPHRASE':
|
||||||
|
return t('settings.backup_wrong_passphrase')
|
||||||
case 'BACKUP_INVALID_FORMAT':
|
case 'BACKUP_INVALID_FORMAT':
|
||||||
return t('settings.backup_invalid_format')
|
return t('settings.backup_invalid_format')
|
||||||
case 'BACKUP_NOT_AUTHENTICATED':
|
case 'BACKUP_NOT_AUTHENTICATED':
|
||||||
@@ -53,12 +61,16 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
|||||||
const [importPassphrase, setImportPassphrase] = useState('')
|
const [importPassphrase, setImportPassphrase] = useState('')
|
||||||
const [importFile, setImportFile] = useState<File | null>(null)
|
const [importFile, setImportFile] = useState<File | null>(null)
|
||||||
const [importPreview, setImportPreview] = useState<LogbookBackupPreview | null>(null)
|
const [importPreview, setImportPreview] = useState<LogbookBackupPreview | null>(null)
|
||||||
const [parsedBackup, setParsedBackup] = useState<LogbookBackupFile | null>(null)
|
const [parsedBackup, setParsedBackup] = useState<ParsedLogbookBackup | null>(null)
|
||||||
const [importing, setImporting] = useState(false)
|
const [importing, setImporting] = useState(false)
|
||||||
const [previewing, setPreviewing] = useState(false)
|
const [previewing, setPreviewing] = useState(false)
|
||||||
|
const [exportProgress, setExportProgress] = useState<string | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [success, setSuccess] = useState<string | null>(null)
|
const [success, setSuccess] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const exportPassphrasesMatch =
|
||||||
|
exportPassphrase.length >= 8 && exportPassphrase === exportConfirm
|
||||||
|
|
||||||
const handleExportSubmit = async (e: React.FormEvent) => {
|
const handleExportSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
await handleExport()
|
await handleExport()
|
||||||
@@ -83,21 +95,36 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
|||||||
}
|
}
|
||||||
|
|
||||||
setExporting(true)
|
setExporting(true)
|
||||||
|
setExportProgress(null)
|
||||||
try {
|
try {
|
||||||
const { blob, filename, backup } = await exportLogbookBackup(logbookId, exportPassphrase)
|
const { blob, filename, manifest } = await exportLogbookBackup(logbookId, exportPassphrase, {
|
||||||
|
onProgress: (p) => {
|
||||||
|
if (p.phase === 'pack') {
|
||||||
|
setExportProgress(
|
||||||
|
t('settings.backup_export_progress', {
|
||||||
|
current: p.current,
|
||||||
|
total: p.total
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
downloadBackupBlob(blob, filename)
|
downloadBackupBlob(blob, filename)
|
||||||
setSuccess(t('settings.backup_export_success', { count: backup.counts.entries }))
|
setSuccess(t('settings.backup_export_success', { count: manifest.counts.entries }))
|
||||||
setExportPassphrase('')
|
setExportPassphrase('')
|
||||||
setExportConfirm('')
|
setExportConfirm('')
|
||||||
trackPlausibleEvent(PlausibleEvents.BACKUP_EXPORTED, {
|
trackPlausibleEvent(PlausibleEvents.BACKUP_EXPORTED, {
|
||||||
entries: backup.counts.entries,
|
entries: manifest.counts.entries,
|
||||||
photos: backup.counts.photos
|
photos: manifest.counts.photos,
|
||||||
|
voiceMemos: manifest.counts.voiceMemos,
|
||||||
|
bytes: manifest.totalUncompressedBytes
|
||||||
})
|
})
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
setError(mapBackupError(message, t))
|
setError(mapBackupError(message, t))
|
||||||
} finally {
|
} finally {
|
||||||
setExporting(false)
|
setExporting(false)
|
||||||
|
setExportProgress(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,6 +165,18 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
|||||||
const handleRestore = async (options: { overwrite?: boolean; assignNewId?: boolean } = {}) => {
|
const handleRestore = async (options: { overwrite?: boolean; assignNewId?: boolean } = {}) => {
|
||||||
if (!parsedBackup || !importPassphrase) return
|
if (!parsedBackup || !importPassphrase) return
|
||||||
|
|
||||||
|
if (parsedBackup.manifest.totalUncompressedBytes > BACKUP_SIZE_CONFIRM_BYTES) {
|
||||||
|
const ok = await showConfirm(
|
||||||
|
t('settings.backup_import_size_confirm', {
|
||||||
|
size: formatBackupBytes(parsedBackup.manifest.totalUncompressedBytes)
|
||||||
|
}),
|
||||||
|
t('settings.backup_restore_title'),
|
||||||
|
t('logs.confirm_yes'),
|
||||||
|
t('logs.confirm_no')
|
||||||
|
)
|
||||||
|
if (!ok) return
|
||||||
|
}
|
||||||
|
|
||||||
setImporting(true)
|
setImporting(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
@@ -149,8 +188,10 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
|||||||
setParsedBackup(null)
|
setParsedBackup(null)
|
||||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||||
trackPlausibleEvent(PlausibleEvents.BACKUP_RESTORED, {
|
trackPlausibleEvent(PlausibleEvents.BACKUP_RESTORED, {
|
||||||
entries: parsedBackup.counts.entries,
|
entries: parsedBackup.manifest.counts.entries,
|
||||||
photos: parsedBackup.counts.photos,
|
photos: parsedBackup.manifest.counts.photos,
|
||||||
|
voiceMemos: parsedBackup.manifest.counts.voiceMemos,
|
||||||
|
bytes: parsedBackup.manifest.totalUncompressedBytes,
|
||||||
mode: options.overwrite ? 'overwrite' : options.assignNewId ? 'new_id' : 'same_id'
|
mode: options.overwrite ? 'overwrite' : options.assignNewId ? 'new_id' : 'same_id'
|
||||||
})
|
})
|
||||||
onRestored?.(result.logbookId, result.title)
|
onRestored?.(result.logbookId, result.title)
|
||||||
@@ -253,11 +294,16 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn primary"
|
className="btn primary"
|
||||||
disabled={exporting || !exportPassphrase || !exportConfirm}
|
disabled={exporting || !exportPassphrasesMatch}
|
||||||
>
|
>
|
||||||
<Download size={16} />
|
<Download size={16} />
|
||||||
{exporting ? t('settings.backup_exporting') : t('settings.backup_export_btn')}
|
{exporting ? t('settings.backup_exporting') : t('settings.backup_export_btn')}
|
||||||
</button>
|
</button>
|
||||||
|
{exportProgress && (
|
||||||
|
<p className="text-muted backup-export-progress" role="status">
|
||||||
|
{exportProgress}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -275,7 +321,7 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
|||||||
id="backup-import-file"
|
id="backup-import-file"
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept=".daagbok.json,application/json"
|
accept=".daagbok,application/zip"
|
||||||
className="input-text"
|
className="input-text"
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
disabled={importing}
|
disabled={importing}
|
||||||
@@ -330,8 +376,14 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
|||||||
<ul className="backup-preview-stats">
|
<ul className="backup-preview-stats">
|
||||||
<li>{t('settings.backup_stat_entries', { count: importPreview.counts.entries })}</li>
|
<li>{t('settings.backup_stat_entries', { count: importPreview.counts.entries })}</li>
|
||||||
<li>{t('settings.backup_stat_photos', { count: importPreview.counts.photos })}</li>
|
<li>{t('settings.backup_stat_photos', { count: importPreview.counts.photos })}</li>
|
||||||
|
<li>{t('settings.backup_stat_voice', { count: importPreview.counts.voiceMemos })}</li>
|
||||||
<li>{t('settings.backup_stat_crew', { count: importPreview.counts.crews })}</li>
|
<li>{t('settings.backup_stat_crew', { count: importPreview.counts.crews })}</li>
|
||||||
<li>{t('settings.backup_stat_tracks', { count: importPreview.counts.gpsTracks })}</li>
|
<li>{t('settings.backup_stat_tracks', { count: importPreview.counts.gpsTracks })}</li>
|
||||||
|
<li className="text-muted">
|
||||||
|
{t('settings.backup_stat_size', {
|
||||||
|
size: formatBackupBytes(importPreview.totalUncompressedBytes)
|
||||||
|
})}
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-muted backup-preview-date">
|
<p className="text-muted backup-preview-date">
|
||||||
{t('settings.backup_exported_at', {
|
{t('settings.backup_exported_at', {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,14 +3,18 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { cycleAppLanguage } from '../utils/i18nLanguages.js'
|
import { cycleAppLanguage } from '../utils/i18nLanguages.js'
|
||||||
import { useSyncIndicator } from '../hooks/useSyncIndicator.js'
|
import { useSyncIndicator } from '../hooks/useSyncIndicator.js'
|
||||||
import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type DecryptedLogbook } from '../services/logbook.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 LogbookRoleBadge from './LogbookRoleBadge.tsx'
|
||||||
import BetaBadge from './BetaBadge.tsx'
|
import BetaBadge from './BetaBadge.tsx'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
|
import { getErrorMessage } from '../utils/errors.js'
|
||||||
import { logoutUser } from '../services/auth.js'
|
import { logoutUser } from '../services/auth.js'
|
||||||
import { useDialog } from './ModalDialog.tsx'
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, User, Wifi, WifiOff, Search, X, CalendarDays, CaseSensitive, ArrowUp, ArrowDown } from 'lucide-react'
|
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, Wifi, WifiOff, Search, X, CalendarDays, CaseSensitive, ArrowUp, ArrowDown } from 'lucide-react'
|
||||||
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
|
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
|
||||||
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
|
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
|
||||||
|
import ProfileHeaderButton from './ProfileHeaderButton.tsx'
|
||||||
|
|
||||||
interface LogbookDashboardProps {
|
interface LogbookDashboardProps {
|
||||||
onSelectLogbook: (id: string, title: string) => void
|
onSelectLogbook: (id: string, title: string) => void
|
||||||
@@ -18,26 +22,6 @@ interface LogbookDashboardProps {
|
|||||||
onOpenProfile: () => void
|
onOpenProfile: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function logbookMatchesFilter(lb: DecryptedLogbook, query: string, locale: string): boolean {
|
|
||||||
const q = query.trim().toLowerCase()
|
|
||||||
if (!q) return true
|
|
||||||
|
|
||||||
if (lb.title.toLowerCase().includes(q)) return true
|
|
||||||
|
|
||||||
const updated = new Date(lb.updatedAt)
|
|
||||||
const year = updated.getFullYear().toString()
|
|
||||||
if (year.includes(q)) return true
|
|
||||||
|
|
||||||
const dateLabel = updated.toLocaleDateString(locale, {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric'
|
|
||||||
}).toLowerCase()
|
|
||||||
if (dateLabel.includes(q)) return true
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
type LogbookSortKey = 'name' | 'date'
|
type LogbookSortKey = 'name' | 'date'
|
||||||
type LogbookSortDirection = 'asc' | 'desc'
|
type LogbookSortDirection = 'asc' | 'desc'
|
||||||
|
|
||||||
@@ -70,11 +54,13 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [filterQuery, setFilterQuery] = useState('')
|
const [filterQuery, setFilterQuery] = useState('')
|
||||||
|
const [searchFieldsByLogbookId, setSearchFieldsByLogbookId] = useState<Map<string, LogbookSearchFields>>(
|
||||||
|
() => new Map()
|
||||||
|
)
|
||||||
const [sortBy, setSortBy] = useState<LogbookSortKey>('date')
|
const [sortBy, setSortBy] = useState<LogbookSortKey>('date')
|
||||||
const [sortDirection, setSortDirection] = useState<LogbookSortDirection>('desc')
|
const [sortDirection, setSortDirection] = useState<LogbookSortDirection>('desc')
|
||||||
const filterInputRef = useRef<HTMLInputElement>(null)
|
const filterInputRef = useRef<HTMLInputElement>(null)
|
||||||
const [online, setOnline] = useState(navigator.onLine)
|
const [online, setOnline] = useState(navigator.onLine)
|
||||||
const [username] = useState(localStorage.getItem('active_username') || 'Skipper')
|
|
||||||
|
|
||||||
const { pendingCount, showSpinner, showPendingWarning, connStatusClassName } = useSyncIndicator()
|
const { pendingCount, showSpinner, showPendingWarning, connStatusClassName } = useSyncIndicator()
|
||||||
|
|
||||||
@@ -95,6 +81,23 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
loadLogbooks()
|
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) => {
|
const loadLogbooks = async (isRefresh = false) => {
|
||||||
if (isRefresh) setRefreshing(true)
|
if (isRefresh) setRefreshing(true)
|
||||||
else setLoading(true)
|
else setLoading(true)
|
||||||
@@ -102,8 +105,8 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
try {
|
try {
|
||||||
const data = await fetchLogbooks()
|
const data = await fetchLogbooks()
|
||||||
setLogbooks(data)
|
setLogbooks(data)
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message || 'Failed to load logbooks')
|
setError(getErrorMessage(err, t('errors.load_failed')))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
setRefreshing(false)
|
setRefreshing(false)
|
||||||
@@ -121,8 +124,8 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
setLogbooks((prev) => [created, ...prev])
|
setLogbooks((prev) => [created, ...prev])
|
||||||
setNewTitle('')
|
setNewTitle('')
|
||||||
trackPlausibleEvent(PlausibleEvents.LOGBOOK_CREATED)
|
trackPlausibleEvent(PlausibleEvents.LOGBOOK_CREATED)
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message || 'Failed to create logbook')
|
setError(getErrorMessage(err, t('errors.save_failed')))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -138,7 +141,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
await deleteLogbook(id)
|
await deleteLogbook(id)
|
||||||
setLogbooks((prev) => prev.filter((lb) => lb.id !== id))
|
setLogbooks((prev) => prev.filter((lb) => lb.id !== id))
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Failed to delete logbook')
|
setError(getErrorMessage(err, t('errors.delete_failed')))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -182,7 +185,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Failed to update logbook title')
|
setError(getErrorMessage(err, t('errors.save_failed')))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -202,12 +205,18 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
|
|
||||||
const filterActive = filterQuery.trim().length > 0
|
const filterActive = filterQuery.trim().length > 0
|
||||||
const filteredOwnedLogbooks = useMemo(
|
const filteredOwnedLogbooks = useMemo(
|
||||||
() => ownedLogbooks.filter((lb) => logbookMatchesFilter(lb, filterQuery, i18n.language)),
|
() =>
|
||||||
[ownedLogbooks, filterQuery, i18n.language]
|
ownedLogbooks.filter((lb) =>
|
||||||
|
logbookMatchesFilter(lb, filterQuery, i18n.language, searchFieldsByLogbookId.get(lb.id))
|
||||||
|
),
|
||||||
|
[ownedLogbooks, filterQuery, i18n.language, searchFieldsByLogbookId]
|
||||||
)
|
)
|
||||||
const filteredSharedLogbooks = useMemo(
|
const filteredSharedLogbooks = useMemo(
|
||||||
() => sharedLogbooks.filter((lb) => logbookMatchesFilter(lb, filterQuery, i18n.language)),
|
() =>
|
||||||
[sharedLogbooks, filterQuery, i18n.language]
|
sharedLogbooks.filter((lb) =>
|
||||||
|
logbookMatchesFilter(lb, filterQuery, i18n.language, searchFieldsByLogbookId.get(lb.id))
|
||||||
|
),
|
||||||
|
[sharedLogbooks, filterQuery, i18n.language, searchFieldsByLogbookId]
|
||||||
)
|
)
|
||||||
const sortedOwnedLogbooks = useMemo(
|
const sortedOwnedLogbooks = useMemo(
|
||||||
() => sortLogbooks(filteredOwnedLogbooks, sortBy, sortDirection, i18n.language),
|
() => sortLogbooks(filteredOwnedLogbooks, sortBy, sortDirection, i18n.language),
|
||||||
@@ -225,10 +234,18 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={lb.id}
|
key={lb.id}
|
||||||
className={`logbook-card glass${lb.isShared ? ' logbook-card--shared' : ''}`}
|
className={`logbook-card glass${lb.isShared ? ' logbook-card--shared' : ''}${isEditingTitle ? ' logbook-card--editing-title' : ''}`}
|
||||||
onClick={() => onSelectLogbook(lb.id, lb.title)}
|
|
||||||
>
|
>
|
||||||
<div className="card-icon">
|
{!isEditingTitle && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="logbook-card-select"
|
||||||
|
onClick={() => onSelectLogbook(lb.id, lb.title)}
|
||||||
|
aria-label={t('dashboard.open_logbook', { title: lb.title })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card-icon" aria-hidden>
|
||||||
<BookOpen size={24} />
|
<BookOpen size={24} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -241,7 +258,6 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
className="logbook-title-inline-edit input-text"
|
className="logbook-title-inline-edit input-text"
|
||||||
value={editingTitleDraft}
|
value={editingTitleDraft}
|
||||||
onChange={(e) => setEditingTitleDraft(e.target.value)}
|
onChange={(e) => setEditingTitleDraft(e.target.value)}
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -370,18 +386,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Skipper profile */}
|
<ProfileHeaderButton onClick={onOpenProfile} />
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-icon skipper-badge"
|
|
||||||
onClick={onOpenProfile}
|
|
||||||
title={t('dashboard.open_profile', { name: username })}
|
|
||||||
aria-label={t('dashboard.open_profile', { name: username })}
|
|
||||||
data-tour="nav-profile"
|
|
||||||
>
|
|
||||||
<User size={18} aria-hidden="true" />
|
|
||||||
<span className="skipper-badge__name">{username}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Lang toggle */}
|
{/* Lang toggle */}
|
||||||
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
|
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
|
||||||
|
|||||||
@@ -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 {
|
interface DialogContextType {
|
||||||
showAlert: (message: string, title?: string, confirmText?: string) => Promise<void>
|
showAlert: (message: string, title?: string, confirmText?: string) => Promise<void>
|
||||||
showConfirm: (message: string, title?: string, confirmText?: string, cancelText?: string) => Promise<boolean>
|
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)
|
const DialogContext = createContext<DialogContextType | undefined>(undefined)
|
||||||
@@ -16,26 +36,36 @@ export function useDialog() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DialogProvider({ children }: { children: React.ReactNode }) {
|
export function DialogProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const titleId = useId()
|
||||||
|
const messageId = useId()
|
||||||
|
const confirmRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [message, setMessage] = useState('')
|
const [message, setMessage] = useState('')
|
||||||
const [type, setType] = useState<'alert' | 'confirm'>('alert')
|
const [type, setType] = useState<'alert' | 'confirm' | 'confirm-leave'>('alert')
|
||||||
const [confirmLabel, setConfirmLabel] = useState('OK')
|
const [confirmLabel, setConfirmLabel] = useState('OK')
|
||||||
const [cancelLabel, setCancelLabel] = useState('Cancel')
|
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> => {
|
const showAlert = useCallback((msg: string, headerTitle?: string, btnText?: string): Promise<void> => {
|
||||||
setMessage(msg)
|
setMessage(msg)
|
||||||
setTitle(headerTitle || '')
|
setTitle(headerTitle || '')
|
||||||
setType('alert')
|
setType('alert')
|
||||||
setConfirmLabel(btnText || 'OK')
|
setConfirmLabel(btnText || t('dialog.ok'))
|
||||||
setIsOpen(true)
|
setIsOpen(true)
|
||||||
|
|
||||||
return new Promise<void>((resolve) => {
|
return new Promise<void>((resolve) => {
|
||||||
resolveRef.current = resolve
|
alertResolveRef.current = resolve
|
||||||
})
|
})
|
||||||
}, [])
|
}, [t])
|
||||||
|
|
||||||
const showConfirm = useCallback((
|
const showConfirm = useCallback((
|
||||||
msg: string,
|
msg: string,
|
||||||
@@ -46,53 +76,164 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setMessage(msg)
|
setMessage(msg)
|
||||||
setTitle(headerTitle || '')
|
setTitle(headerTitle || '')
|
||||||
setType('confirm')
|
setType('confirm')
|
||||||
setConfirmLabel(btnConfirm || 'Yes')
|
setConfirmLabel(btnConfirm || t('dialog.yes'))
|
||||||
setCancelLabel(btnCancel || 'No')
|
setCancelLabel(btnCancel || t('dialog.no'))
|
||||||
setIsOpen(true)
|
setIsOpen(true)
|
||||||
|
|
||||||
return new Promise<boolean>((resolve) => {
|
return new Promise<boolean>((resolve) => {
|
||||||
resolveRef.current = resolve
|
confirmResolveRef.current = resolve
|
||||||
})
|
})
|
||||||
|
}, [t])
|
||||||
|
|
||||||
|
const 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(() => {
|
const handleConfirm = useCallback(() => {
|
||||||
setIsOpen(false)
|
setIsOpen(false)
|
||||||
if (resolveRef.current) {
|
if (type === 'confirm' && confirmResolveRef.current) {
|
||||||
resolveRef.current(type === 'confirm' ? true : undefined)
|
confirmResolveRef.current(true)
|
||||||
resolveRef.current = null
|
confirmResolveRef.current = null
|
||||||
|
} else if (alertResolveRef.current) {
|
||||||
|
alertResolveRef.current()
|
||||||
|
alertResolveRef.current = null
|
||||||
}
|
}
|
||||||
}, [type])
|
}, [type])
|
||||||
|
|
||||||
const handleCancel = useCallback(() => {
|
const handleCancel = useCallback(() => {
|
||||||
setIsOpen(false)
|
if (type === 'confirm-leave') {
|
||||||
if (resolveRef.current) {
|
closeConfirmLeave('stay')
|
||||||
resolveRef.current(false)
|
return
|
||||||
resolveRef.current = null
|
|
||||||
}
|
}
|
||||||
}, [])
|
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(
|
const contextValue = useMemo(
|
||||||
() => ({ showAlert, showConfirm }),
|
() => ({ showAlert, showConfirm, showConfirmLeave }),
|
||||||
[showAlert, showConfirm]
|
[showAlert, showConfirm, showConfirmLeave]
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogContext.Provider value={contextValue}>
|
<DialogContext.Provider value={contextValue}>
|
||||||
{children}
|
{children}
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="custom-dialog-overlay" onClick={type === 'alert' ? handleConfirm : undefined}>
|
<div
|
||||||
<div className="custom-dialog-card glass scale-in" onClick={(e) => e.stopPropagation()}>
|
className="custom-dialog-overlay"
|
||||||
{title && <h3 className="custom-dialog-title">{title}</h3>}
|
onClick={type === 'confirm' || type === 'confirm-leave' ? handleCancel : handleConfirm}
|
||||||
<p className="custom-dialog-message">{message}</p>
|
>
|
||||||
|
<div
|
||||||
|
className="custom-dialog-card glass scale-in"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={title ? titleId : undefined}
|
||||||
|
aria-describedby={messageId}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{title && (
|
||||||
|
<h3 id={titleId} className="custom-dialog-title">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
|
<p id={messageId} className="custom-dialog-message">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
<div className="custom-dialog-actions">
|
<div className="custom-dialog-actions">
|
||||||
{type === 'confirm' && (
|
{type === 'confirm-leave' ? (
|
||||||
<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 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 danger"
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,
|
enableCollaboratorChangePush,
|
||||||
fetchPushPrefs,
|
fetchPushPrefs,
|
||||||
getNotificationPermission,
|
getNotificationPermission,
|
||||||
isPushSupported
|
isPushSupported,
|
||||||
|
preloadPushService
|
||||||
} from '../services/pushNotifications.js'
|
} from '../services/pushNotifications.js'
|
||||||
import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
|
import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
@@ -28,6 +29,7 @@ export default function PushNotificationSettings() {
|
|||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
void preloadPushService()
|
||||||
try {
|
try {
|
||||||
const prefs = await fetchPushPrefs()
|
const prefs = await fetchPushPrefs()
|
||||||
setEnabled(prefs.collaboratorChangesEnabled)
|
setEnabled(prefs.collaboratorChangesEnabled)
|
||||||
@@ -56,7 +58,8 @@ export default function PushNotificationSettings() {
|
|||||||
trackPlausibleEvent(PlausibleEvents.PUSH_DISABLED)
|
trackPlausibleEvent(PlausibleEvents.PUSH_DISABLED)
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} 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)
|
showAlert(message)
|
||||||
void loadPrefs()
|
void loadPrefs()
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -3,8 +3,14 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { cycleAppLanguage, getNextLanguage, isGermanLocale } from '../utils/i18nLanguages.js'
|
import { cycleAppLanguage, getNextLanguage, isGermanLocale } from '../utils/i18nLanguages.js'
|
||||||
import { decryptJson } from '../services/crypto.js'
|
import { decryptJson } from '../services/crypto.js'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
import VesselForm from './VesselForm.tsx'
|
import LogbookVesselPicker from './LogbookVesselPicker.tsx'
|
||||||
import CrewForm from './CrewForm.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 LogEntriesList from './LogEntriesList.tsx'
|
||||||
import { Ship, Users, FileText, Lock, AlertCircle, Globe } from 'lucide-react'
|
import { Ship, Users, FileText, Lock, AlertCircle, Globe } from 'lucide-react'
|
||||||
|
|
||||||
@@ -31,9 +37,16 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
|||||||
// Logbook data states
|
// Logbook data states
|
||||||
const [logbookTitle, setLogbookTitle] = useState('Logbook')
|
const [logbookTitle, setLogbookTitle] = useState('Logbook')
|
||||||
const [yacht, setYacht] = useState<any>(null)
|
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 [entries, setEntries] = useState<any[]>([])
|
||||||
const [photos, setPhotos] = useState<any[]>([])
|
const [photos, setPhotos] = useState<any[]>([])
|
||||||
|
const [voiceMemos, setVoiceMemos] = useState<any[]>([])
|
||||||
const [gpsTracks, setGpsTracks] = useState<any[]>([])
|
const [gpsTracks, setGpsTracks] = useState<any[]>([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -71,18 +84,67 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
|||||||
}
|
}
|
||||||
setYacht(decYacht)
|
setYacht(decYacht)
|
||||||
|
|
||||||
// Decrypt Crews
|
if (data.logbookCrewSelection) {
|
||||||
const decCrews = []
|
const decSel = await decryptJson(
|
||||||
if (data.crews) {
|
data.logbookCrewSelection.encryptedData,
|
||||||
for (const c of data.crews) {
|
data.logbookCrewSelection.iv,
|
||||||
const dec = await decryptJson(c.encryptedData, c.iv, c.tag, keyBuffer)
|
data.logbookCrewSelection.tag,
|
||||||
decCrews.push({
|
keyBuffer
|
||||||
payloadId: c.payloadId,
|
)
|
||||||
data: dec
|
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
|
// Decrypt Entries
|
||||||
const decEntries = []
|
const decEntries = []
|
||||||
@@ -113,6 +175,23 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
|||||||
}
|
}
|
||||||
setPhotos(decPhotos)
|
setPhotos(decPhotos)
|
||||||
|
|
||||||
|
const decVoiceMemos = []
|
||||||
|
if (data.voiceMemos) {
|
||||||
|
for (const v of data.voiceMemos) {
|
||||||
|
const dec = await decryptJson(v.encryptedData, v.iv, v.tag, keyBuffer)
|
||||||
|
if (dec) {
|
||||||
|
decVoiceMemos.push({
|
||||||
|
payloadId: v.payloadId,
|
||||||
|
audio: dec.audio,
|
||||||
|
mimeType: dec.mimeType,
|
||||||
|
durationSec: dec.durationSec,
|
||||||
|
caption: dec.caption || ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setVoiceMemos(decVoiceMemos)
|
||||||
|
|
||||||
// Decrypt GPS Tracks
|
// Decrypt GPS Tracks
|
||||||
const decGpsTracks = []
|
const decGpsTracks = []
|
||||||
if (data.gpsTracks) {
|
if (data.gpsTracks) {
|
||||||
@@ -221,23 +300,27 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
|||||||
preloadedYacht={yacht}
|
preloadedYacht={yacht}
|
||||||
preloadedEntries={entries}
|
preloadedEntries={entries}
|
||||||
preloadedPhotos={photos}
|
preloadedPhotos={photos}
|
||||||
|
preloadedVoiceMemos={voiceMemos}
|
||||||
preloadedGpsTracks={gpsTracks}
|
preloadedGpsTracks={gpsTracks}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'vessel' && (
|
{activeTab === 'vessel' && (
|
||||||
<VesselForm
|
<LogbookVesselPicker
|
||||||
logbookId="shared"
|
logbookId="shared"
|
||||||
readOnly={true}
|
readOnly={true}
|
||||||
preloadedData={yacht}
|
selectionOnly={true}
|
||||||
|
preloadedSelection={logbookVesselSelection}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'crew' && (
|
{activeTab === 'crew' && (
|
||||||
<CrewForm
|
<LogbookCrewPicker
|
||||||
logbookId="shared"
|
logbookId="shared"
|
||||||
readOnly={true}
|
readOnly={true}
|
||||||
preloadedData={crews}
|
selectionOnly={true}
|
||||||
|
preloadedPool={legacyCrews.length > 0 ? legacyCrews : undefined}
|
||||||
|
preloadedSelection={logbookCrewSelection}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -28,19 +28,19 @@ export default function RegistrationDisclaimer({
|
|||||||
className={`auth-card glass registration-disclaimer${variant === 'view' ? ' registration-disclaimer--modal' : ''}`}
|
className={`auth-card glass registration-disclaimer${variant === 'view' ? ' registration-disclaimer--modal' : ''}`}
|
||||||
role="document"
|
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">
|
<div className="auth-header">
|
||||||
<ScrollText className="auth-icon accent" size={48} />
|
<ScrollText className="auth-icon accent" size={48} />
|
||||||
<h2>{t('disclaimer.title')}</h2>
|
<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>
|
</div>
|
||||||
|
|
||||||
<p className="registration-disclaimer__intro">{t('disclaimer.intro')}</p>
|
<p className="registration-disclaimer__intro">{t('disclaimer.intro')}</p>
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import { apiFetch } from '../services/api.js'
|
|||||||
import {
|
import {
|
||||||
enableCollaboratorChangePush,
|
enableCollaboratorChangePush,
|
||||||
isCollaboratorPushActive,
|
isCollaboratorPushActive,
|
||||||
isPushSupported
|
isPushSupported,
|
||||||
|
preloadPushService
|
||||||
} from '../services/pushNotifications.js'
|
} from '../services/pushNotifications.js'
|
||||||
import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
|
import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
|
||||||
|
|
||||||
@@ -55,6 +56,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
|||||||
loadCollaborators()
|
loadCollaborators()
|
||||||
loadShareLink()
|
loadShareLink()
|
||||||
}
|
}
|
||||||
|
void preloadPushService()
|
||||||
}, [logbookId])
|
}, [logbookId])
|
||||||
|
|
||||||
const loadShareLink = async () => {
|
const loadShareLink = async () => {
|
||||||
@@ -191,7 +193,8 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
|||||||
trackPlausibleEvent(PlausibleEvents.PUSH_ENABLED)
|
trackPlausibleEvent(PlausibleEvents.PUSH_ENABLED)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('Failed to enable push after invite:', err)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,7 +429,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
|||||||
<td>
|
<td>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn-icon logout"
|
className="btn-icon danger"
|
||||||
onClick={() => handleRevoke(c.id, c.username)}
|
onClick={() => handleRevoke(c.id, c.username)}
|
||||||
title="Revoke access"
|
title="Revoke access"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from '../services/statsAggregation.js'
|
} from '../services/statsAggregation.js'
|
||||||
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
|
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
|
||||||
import { formatFuelPerMotorHour } from '../utils/fuelStats.js'
|
import { formatFuelPerMotorHour } from '../utils/fuelStats.js'
|
||||||
|
import { formatAppDecimal } from '../utils/numberFormat.js'
|
||||||
import {
|
import {
|
||||||
loadLogbookEventSeries,
|
loadLogbookEventSeries,
|
||||||
type EventSeriesPoint,
|
type EventSeriesPoint,
|
||||||
@@ -211,8 +212,8 @@ function PropulsionBreakdown({ totals }: { totals: StatsTotals }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="stats-propulsion-labels">
|
<div className="stats-propulsion-labels">
|
||||||
<span>{t('stats.sail_distance')}: {formatNm(totals.sailDistanceNm)} {t('stats.unit_nm')} ({sailPct.toFixed(0)}%)</span>
|
<span>{t('stats.sail_distance')}: {formatNm(totals.sailDistanceNm)} {t('stats.unit_nm')} ({formatAppDecimal(sailPct, { maximumFractionDigits: 0 })}%)</span>
|
||||||
<span>{t('stats.motor_distance')}: {formatNm(totals.motorDistanceNm)} {t('stats.unit_nm')} ({motorPct.toFixed(0)}%)</span>
|
<span>{t('stats.motor_distance')}: {formatNm(totals.motorDistanceNm)} {t('stats.unit_nm')} ({formatAppDecimal(motorPct, { maximumFractionDigits: 0 })}%)</span>
|
||||||
{totals.unknownPropulsionNm > 0 && (
|
{totals.unknownPropulsionNm > 0 && (
|
||||||
<span>{t('stats.unknown_propulsion')}: {formatNm(totals.unknownPropulsionNm)} {t('stats.unit_nm')}</span>
|
<span>{t('stats.unknown_propulsion')}: {formatNm(totals.unknownPropulsionNm)} {t('stats.unit_nm')}</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useCallback } from 'react'
|
import React, { useCallback } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { clampTankLiters } from '../utils/tankCapacity.js'
|
import { clampTankLiters } from '../utils/tankCapacity.js'
|
||||||
|
import { formatTankLiters, parseAppDecimalOrZero } from '../utils/numberFormat.js'
|
||||||
|
|
||||||
interface TankLiterInputProps {
|
interface TankLiterInputProps {
|
||||||
id?: string
|
id?: string
|
||||||
@@ -13,10 +14,8 @@ interface TankLiterInputProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseInputLiters(value: string): number {
|
function parseInputLiters(value: string): number {
|
||||||
const trimmed = value.trim().replace(',', '.')
|
if (!value.trim()) return 0
|
||||||
if (!trimmed) return 0
|
return parseAppDecimalOrZero(value)
|
||||||
const parsed = Number(trimmed)
|
|
||||||
return Number.isFinite(parsed) ? parsed : 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TankLiterInput({
|
export default function TankLiterInput({
|
||||||
@@ -34,8 +33,7 @@ export default function TankLiterInput({
|
|||||||
const emitValue = useCallback(
|
const emitValue = useCallback(
|
||||||
(liters: number) => {
|
(liters: number) => {
|
||||||
const clamped = clampTankLiters(liters, useSlider ? maxLiters : undefined)
|
const clamped = clampTankLiters(liters, useSlider ? maxLiters : undefined)
|
||||||
const str =
|
const str = formatTankLiters(clamped)
|
||||||
Number.isInteger(clamped) ? String(clamped) : String(Number(clamped.toFixed(1)))
|
|
||||||
onChange(str)
|
onChange(str)
|
||||||
},
|
},
|
||||||
[onChange, maxLiters, useSlider]
|
[onChange, maxLiters, useSlider]
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
Anchor,
|
Anchor,
|
||||||
Gauge,
|
Gauge,
|
||||||
Sailboat,
|
Sailboat,
|
||||||
|
Ship,
|
||||||
Timer,
|
Timer,
|
||||||
Share2,
|
Share2,
|
||||||
Calendar,
|
Calendar,
|
||||||
@@ -30,6 +31,10 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import AccountDangerZone from './AccountDangerZone.tsx'
|
import AccountDangerZone from './AccountDangerZone.tsx'
|
||||||
import UserProfilePreferences from './UserProfilePreferences.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 BetaBadge from './BetaBadge.tsx'
|
||||||
import { useDialog } from './ModalDialog.tsx'
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
import {
|
import {
|
||||||
@@ -136,6 +141,11 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
|||||||
connStatusClassName
|
connStatusClassName
|
||||||
} = useSyncIndicator()
|
} = useSyncIndicator()
|
||||||
|
|
||||||
|
const { isActive: tourActive, currentStepId: tourStepId } = useAppTour()
|
||||||
|
const fleetSectionTourOpen =
|
||||||
|
tourActive &&
|
||||||
|
(tourStepId === 'profile_vessel_pool' || tourStepId === 'profile_crew_pool')
|
||||||
|
|
||||||
const sharedLogbookCount = useLiveQuery(
|
const sharedLogbookCount = useLiveQuery(
|
||||||
() => db.logbooks.filter((lb) => lb.isShared === 1).count(),
|
() => db.logbooks.filter((lb) => lb.isShared === 1).count(),
|
||||||
[]
|
[]
|
||||||
@@ -443,8 +453,14 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
|||||||
</section>
|
</section>
|
||||||
) : profile ? (
|
) : profile ? (
|
||||||
<>
|
<>
|
||||||
|
<ProfileAccordionSection
|
||||||
|
id="account"
|
||||||
|
title={t('profile.sections.account')}
|
||||||
|
icon={<User size={20} aria-hidden="true" />}
|
||||||
|
defaultOpen
|
||||||
|
>
|
||||||
<div data-tour="profile-preferences">
|
<div data-tour="profile-preferences">
|
||||||
<section className="form-card">
|
<section className="form-card profile-accordion-inner-card">
|
||||||
<div className="form-header">
|
<div className="form-header">
|
||||||
<User size={24} className="form-icon" />
|
<User size={24} className="form-icon" />
|
||||||
<h2>{t('profile.identity_title')}</h2>
|
<h2>{t('profile.identity_title')}</h2>
|
||||||
@@ -486,8 +502,25 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
|||||||
|
|
||||||
<UserProfilePreferences userId={profile.userId} />
|
<UserProfilePreferences userId={profile.userId} />
|
||||||
</div>
|
</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">
|
<div className="profile-section-header">
|
||||||
<Shield size={20} />
|
<Shield size={20} />
|
||||||
<h3>{t('profile.security_title')}</h3>
|
<h3>{t('profile.security_title')}</h3>
|
||||||
@@ -726,7 +759,14 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<div className="form-header">
|
||||||
<BarChart2 size={24} className="form-icon" />
|
<BarChart2 size={24} className="form-icon" />
|
||||||
<div>
|
<div>
|
||||||
@@ -788,8 +828,14 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
</ProfileAccordionSection>
|
||||||
|
|
||||||
<AccountDangerZone className="mt-6" />
|
<ProfileAccordionSection
|
||||||
|
id="danger"
|
||||||
|
title={t('profile.sections.danger')}
|
||||||
|
>
|
||||||
|
<AccountDangerZone className="profile-accordion-inner-card" />
|
||||||
|
</ProfileAccordionSection>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</main>
|
</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 danger"
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { useEffect, useState, useRef } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { db } from '../services/db.js'
|
||||||
|
import { getActiveMasterKey } from '../services/auth.js'
|
||||||
|
import { getLogbookKey } from '../services/logbookKeys.js'
|
||||||
|
import { decryptJson } from '../services/crypto.js'
|
||||||
|
|
||||||
|
export interface PreloadedVoiceMemo {
|
||||||
|
payloadId: string
|
||||||
|
audio: string
|
||||||
|
mimeType?: string
|
||||||
|
durationSec?: number
|
||||||
|
caption?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VoiceMemoPlayerProps {
|
||||||
|
audioId: string
|
||||||
|
logbookId: string
|
||||||
|
preloaded?: PreloadedVoiceMemo | null
|
||||||
|
compact?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VoiceMemoPlayer({
|
||||||
|
audioId,
|
||||||
|
logbookId,
|
||||||
|
preloaded,
|
||||||
|
compact = false
|
||||||
|
}: VoiceMemoPlayerProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [src, setSrc] = useState<string | null>(preloaded?.audio ?? null)
|
||||||
|
const [error, setError] = useState(false)
|
||||||
|
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = audioRef.current
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
const handleLoadedMetadata = () => {
|
||||||
|
if (el.duration === Infinity || isNaN(el.duration) || el.duration === 0) {
|
||||||
|
el.currentTime = 1e10
|
||||||
|
const onTimeUpdate = () => {
|
||||||
|
el.currentTime = 0
|
||||||
|
el.removeEventListener('timeupdate', onTimeUpdate)
|
||||||
|
}
|
||||||
|
el.addEventListener('timeupdate', onTimeUpdate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (el.readyState >= 1) {
|
||||||
|
handleLoadedMetadata()
|
||||||
|
} else {
|
||||||
|
el.addEventListener('loadedmetadata', handleLoadedMetadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (src) {
|
||||||
|
el.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener('loadedmetadata', handleLoadedMetadata)
|
||||||
|
}
|
||||||
|
}, [src])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (preloaded?.audio) {
|
||||||
|
setSrc(preloaded.audio)
|
||||||
|
setError(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const record = await db.voiceMemos.get(audioId)
|
||||||
|
if (!record || cancelled) return
|
||||||
|
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||||
|
if (!masterKey || cancelled) return
|
||||||
|
const decrypted = await decryptJson(record.encryptedData, record.iv, record.tag, masterKey)
|
||||||
|
if (!decrypted?.audio || cancelled) {
|
||||||
|
setError(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSrc(String(decrypted.audio))
|
||||||
|
setError(false)
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setError(true)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [audioId, logbookId, preloaded?.audio])
|
||||||
|
|
||||||
|
if (error || !src) {
|
||||||
|
return (
|
||||||
|
<span className="voice-memo-player-unavailable">
|
||||||
|
{t('logs.live_voice_unavailable')}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const playerClass = compact
|
||||||
|
? 'voice-memo-player voice-memo-player--compact'
|
||||||
|
: 'voice-memo-player'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="voice-memo-player-shell">
|
||||||
|
<audio ref={audioRef} className={playerClass} controls preload="metadata" src={src} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -17,7 +17,13 @@ describe('AppTourContext step order', () => {
|
|||||||
expect(profileIndex).toBeGreaterThan(FULL_STEP_ORDER.indexOf('nav_feedback'))
|
expect(profileIndex).toBeGreaterThan(FULL_STEP_ORDER.indexOf('nav_feedback'))
|
||||||
expect(prefsIndex).toBe(profileIndex + 1)
|
expect(prefsIndex).toBe(profileIndex + 1)
|
||||||
expect(finishIndex).toBe(prefsIndex + 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', () => {
|
it('excludes profile, stats and feedback from demo tour', () => {
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ export type TourStepId =
|
|||||||
| 'entry_open'
|
| 'entry_open'
|
||||||
| 'entry_track'
|
| 'entry_track'
|
||||||
| 'nav_vessel'
|
| 'nav_vessel'
|
||||||
| 'nav_crew'
|
| 'profile_vessel_pool'
|
||||||
|
| 'profile_crew_pool'
|
||||||
|
| 'nav_logbook_crew'
|
||||||
| 'nav_stats'
|
| 'nav_stats'
|
||||||
| 'nav_feedback'
|
| 'nav_feedback'
|
||||||
| 'nav_profile'
|
| 'nav_profile'
|
||||||
@@ -71,7 +73,9 @@ export const FULL_STEP_ORDER: TourStepId[] = [
|
|||||||
'entry_open',
|
'entry_open',
|
||||||
'entry_track',
|
'entry_track',
|
||||||
'nav_vessel',
|
'nav_vessel',
|
||||||
'nav_crew',
|
'profile_vessel_pool',
|
||||||
|
'profile_crew_pool',
|
||||||
|
'nav_logbook_crew',
|
||||||
'nav_stats',
|
'nav_stats',
|
||||||
'nav_feedback',
|
'nav_feedback',
|
||||||
'nav_profile',
|
'nav_profile',
|
||||||
@@ -81,6 +85,7 @@ export const FULL_STEP_ORDER: TourStepId[] = [
|
|||||||
|
|
||||||
/** Public demo has no stats/feedback/profile UI — skip those steps. */
|
/** Public demo has no stats/feedback/profile UI — skip those steps. */
|
||||||
export const DEMO_EXCLUDED_STEPS: TourStepId[] = [
|
export const DEMO_EXCLUDED_STEPS: TourStepId[] = [
|
||||||
|
'profile_crew_pool',
|
||||||
'nav_stats',
|
'nav_stats',
|
||||||
'nav_feedback',
|
'nav_feedback',
|
||||||
'nav_profile',
|
'nav_profile',
|
||||||
@@ -97,7 +102,7 @@ const LOGBOOK_TOUR_STEPS = new Set<TourStepId>([
|
|||||||
'entry_open',
|
'entry_open',
|
||||||
'entry_track',
|
'entry_track',
|
||||||
'nav_vessel',
|
'nav_vessel',
|
||||||
'nav_crew',
|
'nav_logbook_crew',
|
||||||
'nav_stats',
|
'nav_stats',
|
||||||
'nav_feedback'
|
'nav_feedback'
|
||||||
])
|
])
|
||||||
@@ -112,7 +117,9 @@ const TARGET_BY_STEP: Partial<Record<TourStepId, string>> = {
|
|||||||
entry_open: '[data-tour="entry-first"]',
|
entry_open: '[data-tour="entry-first"]',
|
||||||
entry_track: '[data-tour="entry-track"]',
|
entry_track: '[data-tour="entry-track"]',
|
||||||
nav_vessel: '[data-tour="nav-vessel"]',
|
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_stats: '[data-tour="stats-dashboard"]',
|
||||||
nav_feedback: '[data-tour="feedback-form"]',
|
nav_feedback: '[data-tour="feedback-form"]',
|
||||||
nav_profile: '[data-tour="nav-profile"]',
|
nav_profile: '[data-tour="nav-profile"]',
|
||||||
@@ -127,7 +134,14 @@ export function tourStepOpensEntry(stepId: TourStepId): boolean {
|
|||||||
export function getTourTargetDelay(stepId: TourStepId): number {
|
export function getTourTargetDelay(stepId: TourStepId): number {
|
||||||
if (stepId === 'entry_track') return 400
|
if (stepId === 'entry_track') return 400
|
||||||
if (stepId === 'nav_feedback') return 180
|
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
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,8 +197,15 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
|||||||
nav.setSelectedEntryId(null)
|
nav.setSelectedEntryId(null)
|
||||||
nav.setActiveTab('vessel')
|
nav.setActiveTab('vessel')
|
||||||
}
|
}
|
||||||
if (stepId === 'nav_crew') {
|
if (stepId === 'profile_vessel_pool' || stepId === 'profile_crew_pool') {
|
||||||
nav.setSelectedEntryId(null)
|
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')
|
nav.setActiveTab('crew')
|
||||||
}
|
}
|
||||||
if (stepId === 'nav_stats') {
|
if (stepId === 'nav_stats') {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useDialog } from '../components/ModalDialog.tsx'
|
|||||||
|
|
||||||
interface UnsavedChangesContextValue {
|
interface UnsavedChangesContextValue {
|
||||||
setDirty: (source: string, dirty: boolean) => void
|
setDirty: (source: string, dirty: boolean) => void
|
||||||
|
registerSaveHandler: (source: string, handler: (() => Promise<void>) | null) => void
|
||||||
confirmLeave: () => Promise<boolean>
|
confirmLeave: () => Promise<boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,23 +20,51 @@ const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(n
|
|||||||
|
|
||||||
export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
|
export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { showConfirm } = useDialog()
|
const { showConfirmLeave, showAlert } = useDialog()
|
||||||
const dirtySources = useRef(new Set<string>())
|
const dirtySources = useRef(new Set<string>())
|
||||||
|
const saveHandlers = useRef(new Map<string, () => Promise<void>>())
|
||||||
|
|
||||||
const setDirty = useCallback((source: string, dirty: boolean) => {
|
const setDirty = useCallback((source: string, dirty: boolean) => {
|
||||||
if (dirty) dirtySources.current.add(source)
|
if (dirty) dirtySources.current.add(source)
|
||||||
else dirtySources.current.delete(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> => {
|
const confirmLeave = useCallback(async (): Promise<boolean> => {
|
||||||
if (dirtySources.current.size === 0) return true
|
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_message'),
|
||||||
t('common.unsaved_changes_title'),
|
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(() => {
|
useEffect(() => {
|
||||||
const handler = (e: BeforeUnloadEvent) => {
|
const handler = (e: BeforeUnloadEvent) => {
|
||||||
@@ -47,7 +76,10 @@ export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
|
|||||||
return () => window.removeEventListener('beforeunload', handler)
|
return () => window.removeEventListener('beforeunload', handler)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const value = useMemo(() => ({ setDirty, confirmLeave }), [setDirty, confirmLeave])
|
const value = useMemo(
|
||||||
|
() => ({ setDirty, registerSaveHandler, confirmLeave }),
|
||||||
|
[setDirty, registerSaveHandler, confirmLeave]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UnsavedChangesContext.Provider value={value}>
|
<UnsavedChangesContext.Provider value={value}>
|
||||||
@@ -65,13 +97,26 @@ export function useUnsavedChangesContext(): UnsavedChangesContextValue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Register a form/view as having unsaved changes (cleared automatically on unmount). */
|
/** Register a form/view as having unsaved changes (cleared automatically on unmount). */
|
||||||
export function useRegisterUnsavedChanges(source: string, isDirty: boolean) {
|
export function useRegisterUnsavedChanges(
|
||||||
const { setDirty, confirmLeave } = useUnsavedChangesContext()
|
source: string,
|
||||||
|
isDirty: boolean,
|
||||||
|
onSave?: () => Promise<void>
|
||||||
|
) {
|
||||||
|
const { setDirty, registerSaveHandler, confirmLeave } = useUnsavedChangesContext()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDirty(source, isDirty)
|
setDirty(source, isDirty)
|
||||||
return () => setDirty(source, false)
|
return () => setDirty(source, false)
|
||||||
}, [source, isDirty, setDirty])
|
}, [source, isDirty, setDirty])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!onSave) {
|
||||||
|
registerSaveHandler(source, null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
registerSaveHandler(source, onSave)
|
||||||
|
return () => registerSaveHandler(source, null)
|
||||||
|
}, [source, onSave, registerSaveHandler])
|
||||||
|
|
||||||
return { confirmLeave }
|
return { confirmLeave }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useLiveQuery } from 'dexie-react-hooks'
|
||||||
|
import { db } from '../services/db.js'
|
||||||
|
import { getActiveMasterKey } from '../services/auth.js'
|
||||||
|
import { getLogbookKey } from '../services/logbookKeys.js'
|
||||||
|
import { decryptJson } from '../services/crypto.js'
|
||||||
|
import type { PreloadedVoiceMemo } from '../components/VoiceMemoPlayer.tsx'
|
||||||
|
|
||||||
|
export function useEntryVoiceMemos(
|
||||||
|
logbookId: string,
|
||||||
|
entryId: string | null,
|
||||||
|
preloaded?: PreloadedVoiceMemo[]
|
||||||
|
): Map<string, PreloadedVoiceMemo> {
|
||||||
|
const localMemos = useLiveQuery(
|
||||||
|
() => (entryId ? db.voiceMemos.where({ entryId }).toArray() : []),
|
||||||
|
[entryId]
|
||||||
|
)
|
||||||
|
|
||||||
|
const [lookup, setLookup] = useState<Map<string, PreloadedVoiceMemo>>(new Map())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (preloaded && preloaded.length > 0) {
|
||||||
|
const map = new Map<string, PreloadedVoiceMemo>()
|
||||||
|
for (const m of preloaded) {
|
||||||
|
map.set(m.payloadId, m)
|
||||||
|
}
|
||||||
|
setLookup(map)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entryId || !localMemos) {
|
||||||
|
setLookup(new Map())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
void (async () => {
|
||||||
|
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||||
|
if (!masterKey || cancelled) return
|
||||||
|
|
||||||
|
const map = new Map<string, PreloadedVoiceMemo>()
|
||||||
|
for (const row of localMemos) {
|
||||||
|
try {
|
||||||
|
const decrypted = await decryptJson(row.encryptedData, row.iv, row.tag, masterKey)
|
||||||
|
if (!decrypted?.audio) continue
|
||||||
|
map.set(row.payloadId, {
|
||||||
|
payloadId: row.payloadId,
|
||||||
|
audio: String(decrypted.audio),
|
||||||
|
mimeType: decrypted.mimeType ? String(decrypted.mimeType) : undefined,
|
||||||
|
durationSec: typeof decrypted.durationSec === 'number' ? decrypted.durationSec : undefined,
|
||||||
|
caption: decrypted.caption ? String(decrypted.caption) : ''
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// skip corrupt memo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!cancelled) setLookup(map)
|
||||||
|
})()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [localMemos, entryId, logbookId, preloaded])
|
||||||
|
|
||||||
|
return lookup
|
||||||
|
}
|
||||||
@@ -42,12 +42,14 @@ function scheduleUpdateChecks(
|
|||||||
|
|
||||||
const onVisibilityChange = () => {
|
const onVisibilityChange = () => {
|
||||||
if (document.visibilityState === 'visible') {
|
if (document.visibilityState === 'visible') {
|
||||||
checkForUpdate()
|
// Delay check on wake-up to allow the mobile network stack to stabilize
|
||||||
|
setTimeout(checkForUpdate, 2000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onOnline = () => {
|
const onOnline = () => {
|
||||||
checkForUpdate()
|
// Small delay to ensure connection is fully established
|
||||||
|
setTimeout(checkForUpdate, 500)
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||||
|
|||||||
+230
-60
@@ -6,6 +6,10 @@
|
|||||||
"beta": "Beta",
|
"beta": "Beta",
|
||||||
"beta_hint": "Betaversion - funktioner kan stadig ændres"
|
"beta_hint": "Betaversion - funktioner kan stadig ændres"
|
||||||
},
|
},
|
||||||
|
"footer": {
|
||||||
|
"kofi_label": "Ko-fi",
|
||||||
|
"kofi_title": "Støt projektet, videreudvikling og driftsomkostninger på Ko-fi"
|
||||||
|
},
|
||||||
"languages": {
|
"languages": {
|
||||||
"de": "Deutsch",
|
"de": "Deutsch",
|
||||||
"en": "English",
|
"en": "English",
|
||||||
@@ -13,16 +17,29 @@
|
|||||||
"sv": "Svenska",
|
"sv": "Svenska",
|
||||||
"nb": "Norsk"
|
"nb": "Norsk"
|
||||||
},
|
},
|
||||||
|
"dialog": {
|
||||||
|
"ok": "OK",
|
||||||
|
"yes": "Ja",
|
||||||
|
"no": "Nej"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"load_failed": "Data kunne ikke indlæses.",
|
||||||
|
"save_failed": "Ændringer kunne ikke gemmes.",
|
||||||
|
"delete_failed": "Sletning mislykkedes.",
|
||||||
|
"export_failed": "Eksport mislykkedes."
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"unsaved_changes_title": "Ikke gemte ændringer",
|
"unsaved_changes_title": "Ikke gemte ændringer",
|
||||||
"unsaved_changes_message": "Du har ændringer, der ikke er gemt. Vil du virkelig forlade siden? Dine ændringer vil gå tabt.",
|
"unsaved_changes_message": "Du har ændringer, der ikke er gemt. Vil du virkelig forlade siden? Dine ændringer vil gå tabt.",
|
||||||
"unsaved_changes_leave": "Forladelse",
|
"unsaved_changes_stay": "Bliv her",
|
||||||
"unsaved_changes_stay": "Bliv her"
|
"unsaved_changes_save_leave": "Gem og forlad",
|
||||||
|
"unsaved_changes_discard": "Kassér",
|
||||||
|
"unsaved_changes_leave": "Forladelse"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"vessel": "Skibsdata",
|
"vessel": "Skibsdata",
|
||||||
"crew": "Besætningsliste",
|
"crew": "Crew",
|
||||||
"deviation": "Tabel over distraktioner",
|
"deviation": "Tabel over distraktioner",
|
||||||
"logs": "Indlæg i logbogen",
|
"logs": "Indlæg i logbogen",
|
||||||
"stats": "Statistik",
|
"stats": "Statistik",
|
||||||
@@ -92,13 +109,18 @@
|
|||||||
"update_title": "Opdatering tilgængelig",
|
"update_title": "Opdatering tilgængelig",
|
||||||
"update_desc": "En ny version af Kapteins Daagbok er klar. Opdater venligst for at få de seneste ændringer.",
|
"update_desc": "En ny version af Kapteins Daagbok er klar. Opdater venligst for at få de seneste ændringer.",
|
||||||
"update_now": "Opdater nu",
|
"update_now": "Opdater nu",
|
||||||
"update_reloading": "Indlæser..."
|
"update_reloading": "Indlæser...",
|
||||||
|
"storage_persist_hint": "Browseren kan slette offline-data. Tillad permanent lagring, så din logbog forbliver beskyttet."
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
"status_synced": "Synkroniseret",
|
"status_synced": "Synkroniseret",
|
||||||
"status_syncing": "Synkroniser...",
|
"status_syncing": "Synkroniser...",
|
||||||
"status_offline": "Offline-cache",
|
"status_offline": "Offline-cache",
|
||||||
"status_unsynced": "Usynkroniserede ændringer"
|
"status_unsynced": "Usynkroniserede ændringer",
|
||||||
|
"conflict_title": "Synkroniseringskonflikt",
|
||||||
|
"conflict_message": "{{count}} ændring(er) kunne ikke synkroniseres (post {{id}}…). Vælg hvilken version der skal gælde.",
|
||||||
|
"conflict_use_server": "Brug serverversion",
|
||||||
|
"conflict_keep_local": "Behold min version"
|
||||||
},
|
},
|
||||||
"vessel": {
|
"vessel": {
|
||||||
"title": "Skibets stamdata",
|
"title": "Skibets stamdata",
|
||||||
@@ -150,7 +172,8 @@
|
|||||||
"sign_cleared_skipper_re_sign_title": "Skippers underskrift fjernet",
|
"sign_cleared_skipper_re_sign_title": "Skippers underskrift fjernet",
|
||||||
"sign_cleared_skipper_re_sign": "Hændelsesloggen er blevet ændret. Skipperens underskrift er blevet fjernet. Godkend venligst igen.",
|
"sign_cleared_skipper_re_sign": "Hændelsesloggen er blevet ændret. Skipperens underskrift er blevet fjernet. Godkend venligst igen.",
|
||||||
"date": "dato",
|
"date": "dato",
|
||||||
"day_of_travel": "Rejsedag / rejsedag",
|
"day_of_travel": "Rejsedag",
|
||||||
|
"travel_day_number": "Rejsedag {{number}}",
|
||||||
"departure": "Starthavn (rejse fra)",
|
"departure": "Starthavn (rejse fra)",
|
||||||
"destination": "Destinationsport (til)",
|
"destination": "Destinationsport (til)",
|
||||||
"route": "Rejse fra/til",
|
"route": "Rejse fra/til",
|
||||||
@@ -186,16 +209,16 @@
|
|||||||
"sign_badge_skipper_title_valid": "Skipper har udgivet",
|
"sign_badge_skipper_title_valid": "Skipper har udgivet",
|
||||||
"sign_badge_skipper_title_invalid": "Skippers signatur er ugyldig - indholdet er blevet ændret",
|
"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_classic_or_passkey": "Valgfrit: klassisk underskrift eller Passkey-frigivelse ovenfor",
|
||||||
"sign_crew_passkey_hint": "Besætningsmedlemmer med skriveadgang kan frigive via Passkey.",
|
"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_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 besætning skal skrive under igen.",
|
"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 besætningens underskrifter.",
|
"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_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 besætning skal skrive under igen.\n\nVil du gerne fortsætte?",
|
"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_proceed": "Tegn",
|
||||||
"sign_cancel": "Annuller",
|
"sign_cancel": "Annuller",
|
||||||
"sign_cleared_re_sign_title": "Underskrifter fjernet",
|
"sign_cleared_re_sign_title": "Underskrifter fjernet",
|
||||||
"sign_cleared_re_sign": "Logbogsoptegnelsen er blevet ændret. Skipperens og besætningens underskrifter er blevet fjernet. Underskriv venligst igen.",
|
"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!",
|
"no_entries": "Ingen logbogsposter fundet for denne yacht. Opret din første rejsedag!",
|
||||||
"back_to_list": "Tilbage til tidsskriftslisten",
|
"back_to_list": "Tilbage til tidsskriftslisten",
|
||||||
"save": "Gem logbogsside",
|
"save": "Gem logbogsside",
|
||||||
@@ -226,55 +249,84 @@
|
|||||||
"live_sails_confirm": "Indtast",
|
"live_sails_confirm": "Indtast",
|
||||||
"live_sails_confirm_count": "Indtast ({{count}})",
|
"live_sails_confirm_count": "Indtast ({{count}})",
|
||||||
"live_sails": "Sejl: {{sails}}",
|
"live_sails": "Sejl: {{sails}}",
|
||||||
"live_fix": "Fix",
|
"live_position": "Position",
|
||||||
"live_fix_coords": "Fix {{lat}}, {{lng}}",
|
"live_position_coords": "Position {{lat}}, {{lng}}",
|
||||||
"live_fix_manual_hint": "GPS ikke tilgængelig. Indtast bredde- og længdegrad manuelt, eller prøv igen med GPS-knappen.",
|
"live_position_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_position_gps_loading": "Henter GPS-position…",
|
||||||
"live_fix_invalid": "Indtast gyldige koordinater (bredde −90…90, længde −180…180).",
|
"live_position_invalid": "Indtast gyldige koordinater (bredde −90…90, længde −180…180).",
|
||||||
"live_fix_lat_placeholder": "Bredde (Lat)",
|
"live_position_lat_placeholder": "Bredde (Lat)",
|
||||||
"live_fix_lng_placeholder": "Længde (Lng)",
|
"live_position_lng_placeholder": "Længde (Lng)",
|
||||||
"live_photo_btn": "Foto (kamera)",
|
"live_photo_btn": "Foto (kamera)",
|
||||||
"live_photo_capture_btn": "Tag billede",
|
"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_starting": "Starter kamera…",
|
||||||
"live_photo_camera_denied": "Kameraadgang nægtet eller utilgængelig.",
|
"live_photo_camera_denied": "Kameraadgang nægtet eller utilgængelig.",
|
||||||
"live_photo_camera_unavailable": "Kamera understøttes ikke i denne browser.",
|
"live_photo_camera_unavailable": "Kamera understøttes ikke i denne browser.",
|
||||||
|
"live_photo_no_camera": "Der er intet kamera tilgængeligt på denne enhed.",
|
||||||
"live_photo_error": "Foto kunne ikke gemmes.",
|
"live_photo_error": "Foto kunne ikke gemmes.",
|
||||||
"live_photo_entry": "Foto: {{caption}}",
|
"live_photo_entry": "Foto: {{caption}}",
|
||||||
"live_photo_entry_plain": "Foto taget",
|
"live_photo_entry_plain": "Foto taget",
|
||||||
"live_undo_photo_hint": "Foto gemt",
|
"live_undo_photo_hint": "Foto gemt",
|
||||||
|
"live_voice_btn": "Stemmenotat",
|
||||||
|
"live_voice_hint": "Optag en kort stemmenotat (maks. 60 sekunder).",
|
||||||
|
"live_voice_record": "Start optagelse",
|
||||||
|
"live_voice_stop": "Stop optagelse",
|
||||||
|
"live_voice_recording": "Optager {{time}}",
|
||||||
|
"live_voice_save": "Gem",
|
||||||
|
"live_voice_saving": "Gemmer…",
|
||||||
|
"live_voice_retake": "Optag igen",
|
||||||
|
"live_voice_mic_denied": "Mikrofonadgang nægtet eller utilgængelig.",
|
||||||
|
"live_voice_record_failed": "Optagelse mislykkedes. Prøv igen.",
|
||||||
|
"live_voice_unavailable": "Stemmenotat utilgængelig",
|
||||||
|
"live_voice_too_large": "Optagelsen er for stor. Optag venligst kortere.",
|
||||||
|
"live_voice_error": "Kunne ikke gemme stemmenotat.",
|
||||||
|
"live_voice_entry": "Stemmenotat: {{caption}}",
|
||||||
|
"live_voice_entry_plain": "Stemmenotat",
|
||||||
|
"live_voice_caption_label": "Billedtekst (valgfrit)",
|
||||||
|
"live_voice_caption_placeholder": "f.eks. radiokontakt med havnemester",
|
||||||
|
"live_undo_voice_hint": "Stemmenotat gemt",
|
||||||
"live_comment_btn": "Kommentar",
|
"live_comment_btn": "Kommentar",
|
||||||
"live_comment_placeholder": "Indtast tekst…",
|
"live_comment_placeholder": "Indtast tekst…",
|
||||||
"live_comment_confirm": "Indtast",
|
"live_comment_confirm": "Indtast",
|
||||||
"live_gps_error": "GPS-position kunne ikke bestemmes.",
|
"live_gps_error": "GPS-position kunne ikke bestemmes.",
|
||||||
|
"live_gps_start_hint": "Begynd altid dagens rejse med en position.",
|
||||||
"live_event_generic": "Hændelse",
|
"live_event_generic": "Hændelse",
|
||||||
"live_weather_btn": "Vejr",
|
"live_weather_btn": "Vejr",
|
||||||
"live_weather_owm_btn": "Hent OpenWeatherMap-vejr",
|
"live_weather_owm_btn": "Hent OpenWeatherMap-vejr",
|
||||||
"live_weather_owm_loading": "Henter 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_position_required": "Log først en position (Position-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_weather_position_stale": "Den seneste position er ældre end 6 timer. Log en ny position, før du henter vejr.",
|
||||||
"live_wind_btn": "Vind",
|
"live_wind_btn": "Vind",
|
||||||
"live_temp_btn": "T °C",
|
"live_temp_btn": "T °C",
|
||||||
"live_pressure_btn": "Lufttryk",
|
"live_pressure_btn": "Lufttryk",
|
||||||
"live_precip_btn": "Nedbør",
|
"live_precip_btn": "Nedbør",
|
||||||
"live_sea_state_btn": "Søgang",
|
"live_sea_state_btn": "Søgang",
|
||||||
|
"live_visibility_btn": "Sigtbarhed",
|
||||||
"live_course_btn": "Kurs",
|
"live_course_btn": "Kurs",
|
||||||
"live_fuel_btn": "Diesel",
|
"live_fuel_btn": "+ Diesel",
|
||||||
"live_water_btn": "Vand",
|
"live_water_btn": "+ Vand",
|
||||||
"live_wind_entry": "Vind {{value}}",
|
"live_wind_entry": "Vind {{value}}",
|
||||||
"live_temp_entry": "Temperatur {{temp}} °C",
|
"live_temp_entry": "Temperatur {{temp}} °C",
|
||||||
"live_pressure_entry": "Lufttryk {{value}} hPa",
|
"live_pressure_entry": "Lufttryk {{value}} hPa",
|
||||||
"live_precip_entry": "Nedbør {{value}}",
|
"live_precip_entry": "Nedbør {{value}}",
|
||||||
"live_sea_state_entry": "Søgang {{value}}",
|
"live_sea_state_entry": "Søgang {{value}}",
|
||||||
|
"live_visibility_entry": "Sigtbarhed {{value}}",
|
||||||
"live_course_entry": "Kurs {{course}}",
|
"live_course_entry": "Kurs {{course}}",
|
||||||
"live_fuel_entry": "Diesel +{{liters}} L",
|
"live_fuel_entry": "Diesel +{{liters}} L",
|
||||||
"live_water_entry": "Vand +{{liters}} L",
|
"live_water_entry": "Vand +{{liters}} L",
|
||||||
"live_auto_position": "Auto-position",
|
"live_auto_position": "Auto-position",
|
||||||
"live_undo_hint": "Indtastning gemt",
|
"live_undo_hint": "Indtastning gemt",
|
||||||
"live_undo_btn": "Fortryd",
|
"live_undo_btn": "Fortryd",
|
||||||
|
"live_cancel": "Annuller",
|
||||||
"live_pressure_placeholder": "f.eks. 1013",
|
"live_pressure_placeholder": "f.eks. 1013",
|
||||||
"live_temp_placeholder": "f.eks. 18",
|
"live_temp_placeholder": "f.eks. 18",
|
||||||
"live_precip_placeholder": "f.eks. let regn",
|
"live_precip_placeholder": "f.eks. let regn",
|
||||||
"live_sea_state_placeholder": "f.eks. 3",
|
"live_sea_state_placeholder": "f.eks. 3",
|
||||||
|
"live_visibility_placeholder": "f.eks. 10 km",
|
||||||
"live_course_placeholder": "f.eks. 245",
|
"live_course_placeholder": "f.eks. 245",
|
||||||
"live_fuel_placeholder": "Optankede liter",
|
"live_fuel_placeholder": "Optankede liter",
|
||||||
"live_water_placeholder": "Optankede liter",
|
"live_water_placeholder": "Optankede liter",
|
||||||
@@ -292,6 +344,7 @@
|
|||||||
"carry_over_tanks_yes": "Tag over",
|
"carry_over_tanks_yes": "Tag over",
|
||||||
"carry_over_tanks_no": "Start med 0",
|
"carry_over_tanks_no": "Start med 0",
|
||||||
"event_title": "Kronologisk hændelseslog",
|
"event_title": "Kronologisk hændelseslog",
|
||||||
|
"event_creator": "Indtastet af",
|
||||||
"no_events": "Der er endnu ikke indtastet nogen begivenheder for denne rejsedag.",
|
"no_events": "Der er endnu ikke indtastet nogen begivenheder for denne rejsedag.",
|
||||||
"event_time": "Tidspunkt på dagen",
|
"event_time": "Tidspunkt på dagen",
|
||||||
"event_mgk": "MgK-kursus",
|
"event_mgk": "MgK-kursus",
|
||||||
@@ -316,6 +369,12 @@
|
|||||||
"event_wind_direction": "Vindretning",
|
"event_wind_direction": "Vindretning",
|
||||||
"event_wind_strength": "Vindstyrke",
|
"event_wind_strength": "Vindstyrke",
|
||||||
"event_sea_state": "Havets tilstand",
|
"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_weather": "Vejret",
|
||||||
"event_log": "Log (sm)",
|
"event_log": "Log (sm)",
|
||||||
"event_gps": "GPS-position",
|
"event_gps": "GPS-position",
|
||||||
@@ -323,7 +382,26 @@
|
|||||||
"event_location_placeholder": "z. f.eks. Kiel",
|
"event_location_placeholder": "z. f.eks. Kiel",
|
||||||
"event_remarks": "Bemærkninger / hændelser",
|
"event_remarks": "Bemærkninger / hændelser",
|
||||||
"gps_btn": "Hent GPS-koordinater",
|
"gps_btn": "Hent GPS-koordinater",
|
||||||
|
"gps_permission_denied": "Adgang til placering blev nægtet. Tillad det i browser- eller enhedsindstillinger og prøv igen.",
|
||||||
|
"gps_timeout": "GPS fik timeout. Prøv igen udendørs med frit udsyn til himlen.",
|
||||||
|
"gps_position_unavailable": "Intet GPS-signal tilgængeligt. Vent og prøv igen, eller indtast koordinater manuelt.",
|
||||||
|
"gps_unavailable": "GPS understøttes ikke af denne browser eller enhed.",
|
||||||
|
"gps_failed": "GPS-position kunne ikke bestemmes.",
|
||||||
|
"gps_fallback_no_location": "GPS mislykkedes. Angiv et sted under placering/havn, afgang eller destination, eller indtast koordinater manuelt.",
|
||||||
|
"gps_fallback_success": "Koordinater for \"{{location}}\" fundet via stedsnavn (ikke GPS).",
|
||||||
|
"gps_fallback_failed": "GPS og stedsnavnssøgning mislykkedes. Indtast koordinater manuelt.",
|
||||||
|
"gps_quality_excellent": "Stærk GPS-modtagelse (±{{accuracy}} m)",
|
||||||
|
"gps_quality_good": "God GPS-modtagelse (±{{accuracy}} m)",
|
||||||
|
"gps_quality_fair": "Middel GPS-modtagelse (±{{accuracy}} m) – gå udendørs for bedre signal.",
|
||||||
|
"gps_quality_poor": "Svag GPS-modtagelse (±{{accuracy}} m) – sandsynligvis få satellitter. Prøv udendørs igen eller kontroller positionen.",
|
||||||
|
"gps_quality_unknown": "GPS-position overtaget (nøjagtighed ikke rapporteret af enheden).",
|
||||||
|
"gps_live_intro_title": "Placering til live-log",
|
||||||
|
"gps_live_intro_body": "Appen har brug for din placering til automatiske positionsindlæg og GPS-knappen.\n\nTryk på „Tillad placering“ og bekræft i den næste dialog. Du kan altid indtaste position manuelt via „Position“.",
|
||||||
|
"gps_live_intro_allow": "Tillad placering",
|
||||||
|
"gps_live_intro_later": "Senere",
|
||||||
|
"gps_enable_in_settings_hint": "Adgang til placering er blokeret. Du kan tillade det senere i browser- eller enhedsindstillinger (websted / app → Placering).",
|
||||||
"weather_btn": "OpenWeatherMap Kald vejret op",
|
"weather_btn": "OpenWeatherMap Kald vejret op",
|
||||||
|
"weather_offline": "OpenWeatherMap kræver internetforbindelse. Du er offline lige nu.",
|
||||||
"event_wind_pressure": "Lufttryk (hPa)",
|
"event_wind_pressure": "Lufttryk (hPa)",
|
||||||
"event_heel": "Krængning (°)",
|
"event_heel": "Krængning (°)",
|
||||||
"event_sails": "Sejlhåndtering/motor",
|
"event_sails": "Sejlhåndtering/motor",
|
||||||
@@ -337,6 +415,18 @@
|
|||||||
"share_csv": "CSV andel",
|
"share_csv": "CSV andel",
|
||||||
"export_pdf": "Download PDF.",
|
"export_pdf": "Download PDF.",
|
||||||
"exporting_pdf": "PDF er genereret...",
|
"exporting_pdf": "PDF er genereret...",
|
||||||
|
"ai_summary_title": "AI-resumé",
|
||||||
|
"ai_summary_read_only": "Oprettet af skipperen — kun læsning for besætningen.",
|
||||||
|
"ai_summary_empty": "Intet resumé endnu.",
|
||||||
|
"ai_summary_generate": "Generér resumé",
|
||||||
|
"ai_summary_regenerate": "Generér igen",
|
||||||
|
"ai_summary_generating": "Genererer…",
|
||||||
|
"ai_summary_attempts_remaining": "{{remaining}} af {{max}} forsøg tilbage",
|
||||||
|
"ai_summary_error": "AI-resumé mislykkedes. Prøv igen senere.",
|
||||||
|
"ai_summary_error_no_key": "Ingen OpenRouter API-nøgle konfigureret på serveren.",
|
||||||
|
"ai_summary_error_rate_limited": "Maksimalt antal genereringer nået for denne rejsedag.",
|
||||||
|
"ai_summary_error_forbidden": "Kun skipperen må generere AI-resuméer.",
|
||||||
|
"ai_summary_offline": "AI-resumé kræver internetforbindelse. Du er offline lige nu.",
|
||||||
"photos_title": "Vedhæftede billeder (E2E-krypteret)",
|
"photos_title": "Vedhæftede billeder (E2E-krypteret)",
|
||||||
"photo_caption_label": "Fotobeskrivelse/etiket (valgfrit)",
|
"photo_caption_label": "Fotobeskrivelse/etiket (valgfrit)",
|
||||||
"photo_caption_placeholder": "f.eks. at sætte sejl nær indsejlingen til havnen",
|
"photo_caption_placeholder": "f.eks. at sætte sejl nær indsejlingen til havnen",
|
||||||
@@ -364,12 +454,12 @@
|
|||||||
"track_map_error": "Kortet kunne ikke indlæses.",
|
"track_map_error": "Kortet kunne ikke indlæses.",
|
||||||
"exporting": "Eksport...",
|
"exporting": "Eksport...",
|
||||||
"share_unsupported": "Deling understøttes ikke på denne enhed. Filen er blevet downloadet i stedet.",
|
"share_unsupported": "Deling understøttes ikke på denne enhed. Filen er blevet downloadet i stedet.",
|
||||||
"invite_crew": "Inviter besætningen",
|
"invite_crew": "Inviter crewen",
|
||||||
"invite_link_copied": "Invitationslink kopieret til udklipsholderen!",
|
"invite_link_copied": "Invitationslink kopieret til udklipsholderen!",
|
||||||
"invite_link_desc": "Del dette link med besætningsmedlemmer for at give dem skriveadgang til denne logbog.",
|
"invite_link_desc": "Del dette link med Crew-medlemmer for at give dem skriveadgang til denne logbog.",
|
||||||
"collaborators_list": "Medlemmer / besætning",
|
"collaborators_list": "Medlemmer / crew",
|
||||||
"revoke": "Fjerne",
|
"revoke": "Fjerne",
|
||||||
"revoke_confirm": "Er du sikker på, at du vil tilbagekalde dette besætningsmedlems adgang?",
|
"revoke_confirm": "Er du sikker på, at du vil tilbagekalde dette Crew-medlems adgang?",
|
||||||
"invite_role": "Rolle",
|
"invite_role": "Rolle",
|
||||||
"invite_expires": "Linket er gyldigt i 48 timer",
|
"invite_expires": "Linket er gyldigt i 48 timer",
|
||||||
"nmea_import_title": "Import NMEA log",
|
"nmea_import_title": "Import NMEA log",
|
||||||
@@ -415,8 +505,8 @@
|
|||||||
"nmea_change_engine_stop": "Engine off",
|
"nmea_change_engine_stop": "Engine off",
|
||||||
"nmea_change_autopilot_on": "Autopilot on",
|
"nmea_change_autopilot_on": "Autopilot on",
|
||||||
"nmea_change_autopilot_off": "Autopilot off",
|
"nmea_change_autopilot_off": "Autopilot off",
|
||||||
"nmea_change_gps_lost": "GPS fix lost",
|
"nmea_change_gps_lost": "GPS-position mistet",
|
||||||
"nmea_change_gps_regained": "GPS fix restored",
|
"nmea_change_gps_regained": "GPS-position gendannet",
|
||||||
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
|
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
|
||||||
"nmea_change_departure": "Departure / underway",
|
"nmea_change_departure": "Departure / underway",
|
||||||
"nmea_change_anchor": "Anchored / stop",
|
"nmea_change_anchor": "Anchored / stop",
|
||||||
@@ -430,7 +520,7 @@
|
|||||||
"new_logbook_placeholder": "Navn på logbog eller yacht",
|
"new_logbook_placeholder": "Navn på logbog eller yacht",
|
||||||
"logout": "Log ud",
|
"logout": "Log ud",
|
||||||
"logged_in_as": "Logget ind som {{name}}",
|
"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.",
|
"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) 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!",
|
"no_logbooks": "Ingen logbøger fundet. Opret din første logbog for at komme i gang!",
|
||||||
"loading": "Logbøgerne er fyldt op...",
|
"loading": "Logbøgerne er fyldt op...",
|
||||||
"status_synced": "Synkroniseret",
|
"status_synced": "Synkroniseret",
|
||||||
@@ -438,20 +528,21 @@
|
|||||||
"delete_btn": "Slet logbog",
|
"delete_btn": "Slet logbog",
|
||||||
"section_owned": "Mine logbøger",
|
"section_owned": "Mine logbøger",
|
||||||
"section_shared": "Fælles logbøger",
|
"section_shared": "Fælles logbøger",
|
||||||
"section_shared_hint": "Du er blevet inviteret som besætningsmedlem. Skipperprofil og indstillinger tilhører ejeren.",
|
"section_shared_hint": "Du er blevet inviteret som Crew-medlem. Skipperprofil og indstillinger tilhører ejeren.",
|
||||||
"role_owner": "Egen logbog",
|
"role_owner": "Egen logbog",
|
||||||
"role_owner_hint": "Du er ejer og skipper af denne logbog",
|
"role_owner_hint": "Du er ejer og skipper af denne logbog",
|
||||||
"role_crew": "Adgang for besætning",
|
"role_crew": "Adgang for crew",
|
||||||
"role_crew_hint": "Inviteret logbog - du kan arbejde som besætning og underskrive den",
|
"role_crew_hint": "Inviteret logbog - du kan arbejde som crew og underskrive den",
|
||||||
"role_read": "Læs kun",
|
"role_read": "Læs kun",
|
||||||
"role_read_hint": "Opdelt logbog - kun visning, ingen redigering",
|
"role_read_hint": "Opdelt logbog - kun visning, ingen redigering",
|
||||||
"open_profile": "Åben profil af {{name}}",
|
"open_profile": "Åben profil af {{name}}",
|
||||||
|
"open_logbook": "Åbn logbog „{{title}}“",
|
||||||
"edit_title": "Omdøb logbog",
|
"edit_title": "Omdøb logbog",
|
||||||
"edit_placeholder": "Nyt navn på logbogen",
|
"edit_placeholder": "Nyt navn på logbogen",
|
||||||
"edit_success": "Logbog omdøbt med succes",
|
"edit_success": "Logbog omdøbt med succes",
|
||||||
"edit_btn": "Omdøb",
|
"edit_btn": "Omdøb",
|
||||||
"filter_label": "Filtrer logbøger",
|
"filter_label": "Filtrer logbøger",
|
||||||
"filter_placeholder": "Navn, årstal eller dato ...",
|
"filter_placeholder": "Navn, årstal, dato, crew eller skib …",
|
||||||
"filter_clear": "Nulstil filter",
|
"filter_clear": "Nulstil filter",
|
||||||
"filter_results": "{{count}} Hits",
|
"filter_results": "{{count}} Hits",
|
||||||
"filter_no_results": "Ingen logbøger matcher din søgning. Prøv med et andet navn eller et andet år.",
|
"filter_no_results": "Ingen logbøger matcher din søgning. Prøv med et andet navn eller et andet år.",
|
||||||
@@ -576,23 +667,88 @@
|
|||||||
"tour_desc": "Lad dig guide gennem de vigtigste områder i appen igen.",
|
"tour_desc": "Lad dig guide gennem de vigtigste områder i appen igen.",
|
||||||
"tour_restart": "Start turen igen",
|
"tour_restart": "Start turen igen",
|
||||||
"push_title": "Push-meddelelser",
|
"push_title": "Push-meddelelser",
|
||||||
"push_desc": "Som logbogsejer får du besked, når inviterede besætningsmedlemmer synkroniserer ændringer. Intet indhold overføres i ren tekst.",
|
"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 besætningen",
|
"push_enable": "Giv os besked om ændringer i crewen",
|
||||||
"push_active": "Push-meddelelser er aktive på denne enhed.",
|
"push_active": "Push-meddelelser er aktive på denne enhed.",
|
||||||
"push_unsupported": "Push-meddelelser understøttes ikke i denne browser.",
|
"push_unsupported": "Push-meddelelser understøttes ikke i denne browser.",
|
||||||
"push_denied_hint": "Notifikationer er blokeret. Tillad dem i browserens eller enhedens indstillinger.",
|
"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_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."
|
"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": {
|
"crew": {
|
||||||
"title": "Skipper- og besætningsprofiler",
|
"title": "Skipper- og Crew-profiler",
|
||||||
"skipper_section": "Skipper-profil",
|
"skipper_section": "Skipper-profil",
|
||||||
"skipper_read_only_hint": "Skipperprofilen kan kun redigeres af logbogens ejer.",
|
"skipper_read_only_hint": "Skipperprofilen kan kun redigeres af logbogens ejer.",
|
||||||
"crew_section": "Besætningsliste",
|
"crew_section": "Crew-liste",
|
||||||
"add_crew": "Tilføj besætningsmedlem",
|
"add_crew": "Tilføj Crew-medlem",
|
||||||
"edit_crew": "Rediger besætningsmedlem",
|
"edit_crew": "Rediger Crew-medlem",
|
||||||
"no_crew": "Ingen besætningsmedlemmer tilføjet endnu.",
|
"no_crew": "Ingen Crew-medlemmer tilføjet endnu.",
|
||||||
"max_crew": "Det maksimale antal på 5 besætningsmedlemmer er nået.",
|
"max_crew": "Det maksimale antal på 12 Crew-medlemmer i puljen er nået.",
|
||||||
"name": "Navn",
|
"name": "Navn",
|
||||||
"address": "adresse",
|
"address": "adresse",
|
||||||
"birthdate": "Fødselsdag",
|
"birthdate": "Fødselsdag",
|
||||||
@@ -605,8 +761,8 @@
|
|||||||
"save": "Gem skipper-data",
|
"save": "Gem skipper-data",
|
||||||
"save_member": "Gem medlem",
|
"save_member": "Gem medlem",
|
||||||
"saved": "Skipperprofilen er blevet gemt!",
|
"saved": "Skipperprofilen er blevet gemt!",
|
||||||
"loading": "Besætningsfilerne er indlæst.",
|
"loading": "Crew-filerne er indlæst.",
|
||||||
"delete_confirm": "Er du sikker på, at du vil fjerne dette besætningsmedlem?"
|
"delete_confirm": "Er du sikker på, at du vil fjerne dette Crew-medlem?"
|
||||||
},
|
},
|
||||||
"deviation": {
|
"deviation": {
|
||||||
"title": "Tabel over kompasafvigelser",
|
"title": "Tabel over kompasafvigelser",
|
||||||
@@ -628,7 +784,7 @@
|
|||||||
"weather_date_mismatch": "Vejrdata kan kun hentes for i dag ({{today}}). Dette logbogsindlæg er dateret {{date}}.",
|
"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.",
|
"gps_error": "Indtast en placering, eller find GPS-koordinaterne.",
|
||||||
"share_title": "Del logbog (skrivebeskyttet)",
|
"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 besætning. Krypteringsnøglerne overføres aldrig til serveren (de forbliver i hash-delen af URL'en).",
|
"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_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_enable": "Aktivér offentligt link",
|
||||||
"share_copied": "Link kopieret!",
|
"share_copied": "Link kopieret!",
|
||||||
@@ -636,25 +792,25 @@
|
|||||||
"link_qr_hint": "Scan QR-koden med din telefon",
|
"link_qr_hint": "Scan QR-koden med din telefon",
|
||||||
"link_qr_alt": "QR-kode til linket",
|
"link_qr_alt": "QR-kode til linket",
|
||||||
"danger_zone_title": "Farezone",
|
"danger_zone_title": "Farezone",
|
||||||
"danger_zone_desc": "Når du sletter din konto, slettes alle dine Passkey'er, logbøger, skibsdata, besætningsprofiler, rejseindlæg og E2E-nøgler uigenkaldeligt. Denne handling kan ikke fortrydes.",
|
"danger_zone_desc": "Når du sletter din konto, slettes alle dine Passkey'er, logbøger, skibsdata, Crew-profiler, rejseindlæg og E2E-nøgler uigenkaldeligt. Denne handling kan ikke fortrydes.",
|
||||||
"delete_account_btn": "Slet konto uigenkaldeligt",
|
"delete_account_btn": "Slet konto uigenkaldeligt",
|
||||||
"delete_account_confirm_title": "Slette konto?",
|
"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_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_yes": "Ja, slet konto og alle data",
|
||||||
"delete_account_confirm_no": "Annuller",
|
"delete_account_confirm_no": "Annuller",
|
||||||
"delete_account_failed": "Kontoen kunne ikke slettes. Prøv venligst igen.",
|
"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.",
|
"delete_backup_hint": "Tip: Lav sikkerhedskopier af dine logbøger (.daagbok) i indstillingerne for hver logbog, før du sletter dem.",
|
||||||
"deleting_account": "Kontoen vil blive slettet...",
|
"deleting_account": "Kontoen vil blive slettet...",
|
||||||
"invite_push_prompt_title": "Aktivere push-meddelelser?",
|
"invite_push_prompt_title": "Aktivere push-meddelelser?",
|
||||||
"invite_push_prompt_message": "Så snart inviterede besætningsmedlemmer synkroniserer ændringer, kan du blive informeret via push. Intet logbogsindhold sendes i almindelig tekst.",
|
"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 besætningsmedlemmerne 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_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_enable": "Aktiver nu",
|
||||||
"invite_push_prompt_later": "Senere",
|
"invite_push_prompt_later": "Senere",
|
||||||
"invite_push_prompt_success": "Push-meddelelser er aktive på denne enhed.",
|
"invite_push_prompt_success": "Push-meddelelser er aktive på denne enhed.",
|
||||||
"backup_title": "Sikkerhedskopiering og gendannelse",
|
"backup_title": "Sikkerhedskopiering og gendannelse",
|
||||||
"backup_desc": "Komplet krypteret backup af denne logbog (poster, fotos, GPS-spor, besætning, skib). Beskyttet med backup-passphrase - til gendannelse til denne eller en ny konto.",
|
"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_title": "Opret backup",
|
||||||
"backup_export_desc": "Downloader alle lokale data som .daagbok.json. Hold filen og adgangssætningen adskilt og sikker.",
|
"backup_export_desc": "Downloader alle lokale data som et komprimeret .daagbok-arkiv. Hold filen og adgangssætningen adskilt og sikker.",
|
||||||
"backup_restore_title": "Gendan sikkerhedskopi",
|
"backup_restore_title": "Gendan sikkerhedskopi",
|
||||||
"backup_restore_desc": "Gendanner en sikkerhedskopi til din nuværende konto - selv efter registrering af en ny konto.",
|
"backup_restore_desc": "Gendanner en sikkerhedskopi til din nuværende konto - selv efter registrering af en ny konto.",
|
||||||
"backup_passphrase": "Backup-passphrase",
|
"backup_passphrase": "Backup-passphrase",
|
||||||
@@ -666,7 +822,13 @@
|
|||||||
"backup_export_btn": "Download backup",
|
"backup_export_btn": "Download backup",
|
||||||
"backup_exporting": "Sikkerhedskopien er oprettet...",
|
"backup_exporting": "Sikkerhedskopien er oprettet...",
|
||||||
"backup_export_success": "Backup oprettet ({{count}} rejsedage).",
|
"backup_export_success": "Backup oprettet ({{count}} rejsedage).",
|
||||||
"backup_file_label": "Backup-fil (.daagbok.json)",
|
"backup_file_label": "Backup-fil (.daagbok)",
|
||||||
|
"backup_export_progress": "Pakker filer {{current}} / {{total}}…",
|
||||||
|
"backup_invalid_archive": "Filen er ikke et gyldigt backup-arkiv.",
|
||||||
|
"backup_version_unsupported": "Gammelt backup-format (v1). Brug en aktuel .daagbok-backup.",
|
||||||
|
"backup_import_size_confirm": "Denne backup er ca. {{size}} ukomprimeret. Gendannelse kan tage længere tid. Fortsæt?",
|
||||||
|
"backup_stat_voice": "{{count}} stemmenotater",
|
||||||
|
"backup_stat_size": "Ca. {{size}} ukomprimeret",
|
||||||
"backup_preview_btn": "Tjek indhold",
|
"backup_preview_btn": "Tjek indhold",
|
||||||
"backup_previewing": "Tjek...",
|
"backup_previewing": "Tjek...",
|
||||||
"backup_restore_btn": "Gendan",
|
"backup_restore_btn": "Gendan",
|
||||||
@@ -682,7 +844,7 @@
|
|||||||
"backup_new_id_confirm": "Importere backup'en som en ny logbog med et nyt ID?",
|
"backup_new_id_confirm": "Importere backup'en som en ny logbog med et nyt ID?",
|
||||||
"backup_stat_entries": "{{count}} Rejsedage",
|
"backup_stat_entries": "{{count}} Rejsedage",
|
||||||
"backup_stat_photos": "{{count}} Fotos",
|
"backup_stat_photos": "{{count}} Fotos",
|
||||||
"backup_stat_crew": "{{count}} Besætningens poster",
|
"backup_stat_crew": "{{count}} Crew-poster",
|
||||||
"backup_stat_tracks": "{{count}} GPS-spor",
|
"backup_stat_tracks": "{{count}} GPS-spor",
|
||||||
"backup_exported_at": "Eksporteret: {{date}}"
|
"backup_exported_at": "Eksporteret: {{date}}"
|
||||||
},
|
},
|
||||||
@@ -824,7 +986,7 @@
|
|||||||
},
|
},
|
||||||
"welcome_public": {
|
"welcome_public": {
|
||||||
"title": "Velkommen om bord!",
|
"title": "Velkommen om bord!",
|
||||||
"body": "Udforsk vores demo-logbog med tre dages rejse i Kielerfjorden - uden en konto. Denne korte tur viser dig skibsdata, besætning og logbogsposter."
|
"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": {
|
"nav_logs": {
|
||||||
"title": "Indlæg i logbogen",
|
"title": "Indlæg i logbogen",
|
||||||
@@ -843,12 +1005,20 @@
|
|||||||
"body": "Upload GPX-filer, eller se allerede gemte ruter på kortet - inklusive afstand og hastighed."
|
"body": "Upload GPX-filer, eller se allerede gemte ruter på kortet - inklusive afstand og hastighed."
|
||||||
},
|
},
|
||||||
"nav_vessel": {
|
"nav_vessel": {
|
||||||
"title": "Skibsdata",
|
"title": "Skib for logbog",
|
||||||
"body": "Indtast navn, dimensioner og tekniske data for din yacht - udfyld én gang, tilgængelig for alle rejsedage."
|
"body": "Vælg skib fra flåden for denne logbog. Administrer skibe i brugerprofilen under Flåde og besætning."
|
||||||
},
|
},
|
||||||
"nav_crew": {
|
"profile_vessel_pool": {
|
||||||
"title": "Besætningsliste",
|
"title": "Skibsflåde",
|
||||||
"body": "Administrer besætningsmedlemmer og tildel dem rejsedage senere."
|
"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": {
|
"nav_stats": {
|
||||||
"title": "Statistik-dashboard",
|
"title": "Statistik-dashboard",
|
||||||
@@ -874,7 +1044,7 @@
|
|||||||
},
|
},
|
||||||
"seo": {
|
"seo": {
|
||||||
"title": "Kapteins Daagbok - Gratis digital yachtlogbog (reklamefri)",
|
"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, besætnings- og skibsdata - også offline som PWA.",
|
"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",
|
"keywords": "Yachtlogbog, skibslogbog, logbog om bord, sejlads, Passkey, E2E-kryptering, GPS-spor, maritim logbog, gratis, reklamefri, gratis, uden reklame",
|
||||||
"ogImageAlt": "Kapteins Daagbok Logo"
|
"ogImageAlt": "Kapteins Daagbok Logo"
|
||||||
}
|
}
|
||||||
|
|||||||
+203
-33
@@ -6,6 +6,10 @@
|
|||||||
"beta": "Beta",
|
"beta": "Beta",
|
||||||
"beta_hint": "Beta-Version — Funktionen können sich noch ändern"
|
"beta_hint": "Beta-Version — Funktionen können sich noch ändern"
|
||||||
},
|
},
|
||||||
|
"footer": {
|
||||||
|
"kofi_label": "Ko-fi",
|
||||||
|
"kofi_title": "Projekt, Weiterentwicklung und Betriebskosten auf Ko-fi unterstützen"
|
||||||
|
},
|
||||||
"languages": {
|
"languages": {
|
||||||
"de": "Deutsch",
|
"de": "Deutsch",
|
||||||
"en": "English",
|
"en": "English",
|
||||||
@@ -13,16 +17,29 @@
|
|||||||
"sv": "Svenska",
|
"sv": "Svenska",
|
||||||
"nb": "Norsk"
|
"nb": "Norsk"
|
||||||
},
|
},
|
||||||
|
"dialog": {
|
||||||
|
"ok": "OK",
|
||||||
|
"yes": "Ja",
|
||||||
|
"no": "Nein"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"load_failed": "Daten konnten nicht geladen werden.",
|
||||||
|
"save_failed": "Änderungen konnten nicht gespeichert werden.",
|
||||||
|
"delete_failed": "Löschen fehlgeschlagen.",
|
||||||
|
"export_failed": "Export fehlgeschlagen."
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"unsaved_changes_title": "Ungespeicherte Änderungen",
|
"unsaved_changes_title": "Ungespeicherte Änderungen",
|
||||||
"unsaved_changes_message": "Du hast ungespeicherte Änderungen. Möchtest du die Seite wirklich verlassen? Deine Änderungen gehen verloren.",
|
"unsaved_changes_message": "Du hast ungespeicherte Änderungen. Möchtest du die Seite wirklich verlassen? Deine Änderungen gehen verloren.",
|
||||||
"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": {
|
"nav": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"vessel": "Schiffsdaten",
|
"vessel": "Schiffsdaten",
|
||||||
"crew": "Crew-Liste",
|
"crew": "Crew",
|
||||||
"deviation": "Ablenkungstabelle",
|
"deviation": "Ablenkungstabelle",
|
||||||
"logs": "Logbucheinträge",
|
"logs": "Logbucheinträge",
|
||||||
"stats": "Statistik",
|
"stats": "Statistik",
|
||||||
@@ -92,13 +109,18 @@
|
|||||||
"update_title": "Update verfügbar",
|
"update_title": "Update verfügbar",
|
||||||
"update_desc": "Eine neue Version von Kapteins Daagbok ist bereit. Bitte aktualisieren, um die neuesten Änderungen zu erhalten.",
|
"update_desc": "Eine neue Version von Kapteins Daagbok ist bereit. Bitte aktualisieren, um die neuesten Änderungen zu erhalten.",
|
||||||
"update_now": "Jetzt aktualisieren",
|
"update_now": "Jetzt aktualisieren",
|
||||||
"update_reloading": "Wird geladen…"
|
"update_reloading": "Wird geladen…",
|
||||||
|
"storage_persist_hint": "Der Browser kann Offline-Daten löschen. Erlaube dauerhafte Speicherung, damit dein Logbuch geschützt bleibt (in den Browser-Einstellungen oder beim nächsten Hinweis)."
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
"status_synced": "Synchronisiert",
|
"status_synced": "Synchronisiert",
|
||||||
"status_syncing": "Synchronisiere…",
|
"status_syncing": "Synchronisiere…",
|
||||||
"status_offline": "Offline-Cache",
|
"status_offline": "Offline-Cache",
|
||||||
"status_unsynced": "Unsynchronisierte Änderungen"
|
"status_unsynced": "Unsynchronisierte Änderungen",
|
||||||
|
"conflict_title": "Synchronisationskonflikt",
|
||||||
|
"conflict_message": "{{count}} Änderung(en) konnten nicht synchronisiert werden (Eintrag {{id}}…). Bitte wähle, welche Version gelten soll.",
|
||||||
|
"conflict_use_server": "Server-Version übernehmen",
|
||||||
|
"conflict_keep_local": "Meine Version behalten"
|
||||||
},
|
},
|
||||||
"vessel": {
|
"vessel": {
|
||||||
"title": "Schiffs-Stammdaten",
|
"title": "Schiffs-Stammdaten",
|
||||||
@@ -150,7 +172,8 @@
|
|||||||
"sign_cleared_skipper_re_sign_title": "Skipper-Unterschrift entfernt",
|
"sign_cleared_skipper_re_sign_title": "Skipper-Unterschrift entfernt",
|
||||||
"sign_cleared_skipper_re_sign": "Das Ereignisprotokoll wurde geändert. Die Skipper-Unterschrift wurde entfernt. Bitte erneut freigeben.",
|
"sign_cleared_skipper_re_sign": "Das Ereignisprotokoll wurde geändert. Die Skipper-Unterschrift wurde entfernt. Bitte erneut freigeben.",
|
||||||
"date": "Datum",
|
"date": "Datum",
|
||||||
"day_of_travel": "Tag der Reise / Reisetag",
|
"day_of_travel": "Reisetag",
|
||||||
|
"travel_day_number": "Reisetag {{number}}",
|
||||||
"departure": "Start-Hafen (Reise von)",
|
"departure": "Start-Hafen (Reise von)",
|
||||||
"destination": "Ziel-Hafen (nach)",
|
"destination": "Ziel-Hafen (nach)",
|
||||||
"route": "Reise von/nach",
|
"route": "Reise von/nach",
|
||||||
@@ -226,55 +249,84 @@
|
|||||||
"live_sails_confirm": "Eintragen",
|
"live_sails_confirm": "Eintragen",
|
||||||
"live_sails_confirm_count": "Eintragen ({{count}})",
|
"live_sails_confirm_count": "Eintragen ({{count}})",
|
||||||
"live_sails": "Segel: {{sails}}",
|
"live_sails": "Segel: {{sails}}",
|
||||||
"live_fix": "Fix",
|
"live_position": "Position",
|
||||||
"live_fix_coords": "Fix {{lat}}, {{lng}}",
|
"live_position_coords": "Position {{lat}}, {{lng}}",
|
||||||
"live_fix_manual_hint": "GPS nicht verfügbar. Breiten- und Längengrad manuell eingeben oder erneut per GPS-Knopf versuchen.",
|
"live_position_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_position_gps_loading": "GPS-Position wird ermittelt…",
|
||||||
"live_fix_invalid": "Bitte gültige Koordinaten eingeben (Breite −90…90, Länge −180…180).",
|
"live_position_invalid": "Bitte gültige Koordinaten eingeben (Breite −90…90, Länge −180…180).",
|
||||||
"live_fix_lat_placeholder": "Breite (Lat)",
|
"live_position_lat_placeholder": "Breite (Lat)",
|
||||||
"live_fix_lng_placeholder": "Länge (Lng)",
|
"live_position_lng_placeholder": "Länge (Lng)",
|
||||||
"live_photo_btn": "Foto (Kamera)",
|
"live_photo_btn": "Foto (Kamera)",
|
||||||
"live_photo_capture_btn": "Aufnehmen",
|
"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_starting": "Kamera wird gestartet…",
|
||||||
"live_photo_camera_denied": "Kamerazugriff verweigert oder nicht verfügbar.",
|
"live_photo_camera_denied": "Kamerazugriff verweigert oder nicht verfügbar.",
|
||||||
"live_photo_camera_unavailable": "Kamera wird von diesem Browser nicht unterstützt.",
|
"live_photo_camera_unavailable": "Kamera wird von diesem Browser nicht unterstützt.",
|
||||||
|
"live_photo_no_camera": "Auf diesem Gerät ist keine Kamera verfügbar.",
|
||||||
"live_photo_error": "Foto konnte nicht gespeichert werden.",
|
"live_photo_error": "Foto konnte nicht gespeichert werden.",
|
||||||
"live_photo_entry": "Foto: {{caption}}",
|
"live_photo_entry": "Foto: {{caption}}",
|
||||||
"live_photo_entry_plain": "Foto aufgenommen",
|
"live_photo_entry_plain": "Foto aufgenommen",
|
||||||
"live_undo_photo_hint": "Foto gespeichert",
|
"live_undo_photo_hint": "Foto gespeichert",
|
||||||
|
"live_voice_btn": "Sprachnotiz",
|
||||||
|
"live_voice_hint": "Kurze Sprachnotiz aufnehmen (max. 60 Sekunden).",
|
||||||
|
"live_voice_record": "Aufnahme starten",
|
||||||
|
"live_voice_stop": "Aufnahme beenden",
|
||||||
|
"live_voice_recording": "Aufnahme {{time}}",
|
||||||
|
"live_voice_save": "Speichern",
|
||||||
|
"live_voice_saving": "Wird gespeichert…",
|
||||||
|
"live_voice_retake": "Neu aufnehmen",
|
||||||
|
"live_voice_mic_denied": "Mikrofonzugriff verweigert oder nicht verfügbar.",
|
||||||
|
"live_voice_record_failed": "Aufnahme fehlgeschlagen. Bitte erneut versuchen.",
|
||||||
|
"live_voice_unavailable": "Sprachnotiz nicht verfügbar",
|
||||||
|
"live_voice_too_large": "Aufnahme ist zu groß. Bitte kürzer aufnehmen.",
|
||||||
|
"live_voice_error": "Sprachnotiz konnte nicht gespeichert werden.",
|
||||||
|
"live_voice_entry": "Sprachnotiz: {{caption}}",
|
||||||
|
"live_voice_entry_plain": "Sprachnotiz",
|
||||||
|
"live_voice_caption_label": "Beschriftung (optional)",
|
||||||
|
"live_voice_caption_placeholder": "z. B. Funkverkehr mit Hafenmeister",
|
||||||
|
"live_undo_voice_hint": "Sprachnotiz gespeichert",
|
||||||
"live_comment_btn": "Kommentar",
|
"live_comment_btn": "Kommentar",
|
||||||
"live_comment_placeholder": "Freitext eingeben…",
|
"live_comment_placeholder": "Freitext eingeben…",
|
||||||
"live_comment_confirm": "Eintragen",
|
"live_comment_confirm": "Eintragen",
|
||||||
"live_gps_error": "GPS-Position konnte nicht ermittelt werden.",
|
"live_gps_error": "GPS-Position konnte nicht ermittelt werden.",
|
||||||
|
"live_gps_start_hint": "Beginne deine Tagesreise immer mit einer Position.",
|
||||||
"live_event_generic": "Ereignis",
|
"live_event_generic": "Ereignis",
|
||||||
"live_weather_btn": "Wetter",
|
"live_weather_btn": "Wetter",
|
||||||
"live_weather_owm_btn": "OpenWeatherMap Wetter abrufen",
|
"live_weather_owm_btn": "OpenWeatherMap Wetter abrufen",
|
||||||
"live_weather_owm_loading": "Wetter wird geladen…",
|
"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_position_required": "Für Wetter von OpenWeatherMap zuerst eine Position eintragen (Schaltfläche „Position“). 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_weather_position_stale": "Die letzte Position ist älter als 6 Stunden. Bitte erneut eine Position loggen, bevor du Wetter abrufst.",
|
||||||
"live_wind_btn": "Wind",
|
"live_wind_btn": "Wind",
|
||||||
"live_temp_btn": "T °C",
|
"live_temp_btn": "T °C",
|
||||||
"live_pressure_btn": "Luftdruck",
|
"live_pressure_btn": "Luftdruck",
|
||||||
"live_precip_btn": "Niederschlag",
|
"live_precip_btn": "Niederschlag",
|
||||||
"live_sea_state_btn": "Seegang",
|
"live_sea_state_btn": "Seegang",
|
||||||
|
"live_visibility_btn": "Sichtweite",
|
||||||
"live_course_btn": "Kurs",
|
"live_course_btn": "Kurs",
|
||||||
"live_fuel_btn": "Diesel",
|
"live_fuel_btn": "+ Diesel",
|
||||||
"live_water_btn": "Wasser",
|
"live_water_btn": "+ Wasser",
|
||||||
"live_wind_entry": "Wind {{value}}",
|
"live_wind_entry": "Wind {{value}}",
|
||||||
"live_temp_entry": "Temperatur {{temp}} °C",
|
"live_temp_entry": "Temperatur {{temp}} °C",
|
||||||
"live_pressure_entry": "Luftdruck {{value}} hPa",
|
"live_pressure_entry": "Luftdruck {{value}} hPa",
|
||||||
"live_precip_entry": "Niederschlag {{value}}",
|
"live_precip_entry": "Niederschlag {{value}}",
|
||||||
"live_sea_state_entry": "Seegang {{value}}",
|
"live_sea_state_entry": "Seegang {{value}}",
|
||||||
|
"live_visibility_entry": "Sichtweite {{value}}",
|
||||||
"live_course_entry": "Kurs {{course}}",
|
"live_course_entry": "Kurs {{course}}",
|
||||||
"live_fuel_entry": "Diesel +{{liters}} L",
|
"live_fuel_entry": "Diesel +{{liters}} L",
|
||||||
"live_water_entry": "Wasser +{{liters}} L",
|
"live_water_entry": "Wasser +{{liters}} L",
|
||||||
"live_auto_position": "Auto-Position",
|
"live_auto_position": "Auto-Position",
|
||||||
"live_undo_hint": "Eintrag gespeichert",
|
"live_undo_hint": "Eintrag gespeichert",
|
||||||
"live_undo_btn": "Rückgängig",
|
"live_undo_btn": "Rückgängig",
|
||||||
|
"live_cancel": "Abbruch",
|
||||||
"live_pressure_placeholder": "z. B. 1013",
|
"live_pressure_placeholder": "z. B. 1013",
|
||||||
"live_temp_placeholder": "z. B. 18",
|
"live_temp_placeholder": "z. B. 18",
|
||||||
"live_precip_placeholder": "z. B. leichter Regen",
|
"live_precip_placeholder": "z. B. leichter Regen",
|
||||||
"live_sea_state_placeholder": "z. B. 3",
|
"live_sea_state_placeholder": "z. B. 3",
|
||||||
|
"live_visibility_placeholder": "z. B. 10 km",
|
||||||
"live_course_placeholder": "z. B. 245",
|
"live_course_placeholder": "z. B. 245",
|
||||||
"live_fuel_placeholder": "Nachgefüllte Liter",
|
"live_fuel_placeholder": "Nachgefüllte Liter",
|
||||||
"live_water_placeholder": "Nachgefüllte Liter",
|
"live_water_placeholder": "Nachgefüllte Liter",
|
||||||
@@ -292,6 +344,7 @@
|
|||||||
"carry_over_tanks_yes": "Übernehmen",
|
"carry_over_tanks_yes": "Übernehmen",
|
||||||
"carry_over_tanks_no": "Mit 0 starten",
|
"carry_over_tanks_no": "Mit 0 starten",
|
||||||
"event_title": "Chronologisches Ereignisprotokoll",
|
"event_title": "Chronologisches Ereignisprotokoll",
|
||||||
|
"event_creator": "Eingetragen von",
|
||||||
"no_events": "Noch keine Ereignisse für diesen Reisetag eingetragen.",
|
"no_events": "Noch keine Ereignisse für diesen Reisetag eingetragen.",
|
||||||
"event_time": "Uhrzeit",
|
"event_time": "Uhrzeit",
|
||||||
"event_mgk": "MgK Kurs",
|
"event_mgk": "MgK Kurs",
|
||||||
@@ -316,6 +369,12 @@
|
|||||||
"event_wind_direction": "Wind-Richtung",
|
"event_wind_direction": "Wind-Richtung",
|
||||||
"event_wind_strength": "Windstärke",
|
"event_wind_strength": "Windstärke",
|
||||||
"event_sea_state": "Seegang",
|
"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_weather": "Wetter",
|
||||||
"event_log": "Logge (sm)",
|
"event_log": "Logge (sm)",
|
||||||
"event_gps": "GPS-Position",
|
"event_gps": "GPS-Position",
|
||||||
@@ -323,7 +382,26 @@
|
|||||||
"event_location_placeholder": "z. B. Kiel",
|
"event_location_placeholder": "z. B. Kiel",
|
||||||
"event_remarks": "Bemerkungen / Vorkommnisse",
|
"event_remarks": "Bemerkungen / Vorkommnisse",
|
||||||
"gps_btn": "GPS-Koordinaten abrufen",
|
"gps_btn": "GPS-Koordinaten abrufen",
|
||||||
|
"gps_permission_denied": "Standortzugriff wurde verweigert. Bitte in den Browser- oder Geräteeinstellungen erlauben und erneut versuchen.",
|
||||||
|
"gps_timeout": "GPS-Zeitüberschreitung. Bitte erneut versuchen – am besten im Freien mit gutem Empfang.",
|
||||||
|
"gps_position_unavailable": "Kein GPS-Signal verfügbar. Bitte warten oder Koordinaten manuell eingeben.",
|
||||||
|
"gps_unavailable": "GPS wird von diesem Browser oder Gerät nicht unterstützt.",
|
||||||
|
"gps_failed": "GPS-Position konnte nicht ermittelt werden.",
|
||||||
|
"gps_fallback_no_location": "GPS fehlgeschlagen. Bitte einen Ort unter „Ort / Hafen“, Start- oder Zielhafen eintragen, oder Koordinaten manuell eingeben.",
|
||||||
|
"gps_fallback_success": "Koordinaten für „{{location}}“ über den Ortsnamen ermittelt (nicht per GPS).",
|
||||||
|
"gps_fallback_failed": "GPS und Ortsnamen-Suche sind fehlgeschlagen. Bitte Koordinaten manuell eingeben.",
|
||||||
|
"gps_quality_excellent": "Starker GPS-Empfang (±{{accuracy}} m)",
|
||||||
|
"gps_quality_good": "Guter GPS-Empfang (±{{accuracy}} m)",
|
||||||
|
"gps_quality_fair": "Mäßiger GPS-Empfang (±{{accuracy}} m) – für besseren Empfang ins Freie gehen.",
|
||||||
|
"gps_quality_poor": "Schwacher GPS-Empfang (±{{accuracy}} m) – vermutlich wenig Satelliten. Im Freien erneut versuchen oder Position prüfen.",
|
||||||
|
"gps_quality_unknown": "GPS-Position übernommen (Genauigkeit vom Gerät nicht gemeldet).",
|
||||||
|
"gps_live_intro_title": "Standort für Live-Log",
|
||||||
|
"gps_live_intro_body": "Für automatische Positions-Einträge und den GPS-Knopf braucht die App Zugriff auf deinen Standort.\n\nTippe auf „Standort erlauben“ – im nächsten Dialog die Freigabe bestätigen. Du kannst jederzeit manuell unter „Position“ eintragen.",
|
||||||
|
"gps_live_intro_allow": "Standort erlauben",
|
||||||
|
"gps_live_intro_later": "Später",
|
||||||
|
"gps_enable_in_settings_hint": "Standortzugriff ist blockiert. In den Browser- oder Geräteeinstellungen (Website / App → Standort) kannst du die Freigabe nachträglich erlauben.",
|
||||||
"weather_btn": "OpenWeatherMap Wetter abrufen",
|
"weather_btn": "OpenWeatherMap Wetter abrufen",
|
||||||
|
"weather_offline": "OpenWeatherMap erfordert eine Internetverbindung. Du bist derzeit offline.",
|
||||||
"event_wind_pressure": "Luftdruck (hPa)",
|
"event_wind_pressure": "Luftdruck (hPa)",
|
||||||
"event_heel": "Krängung (°)",
|
"event_heel": "Krängung (°)",
|
||||||
"event_sails": "Segelführung / Motor",
|
"event_sails": "Segelführung / Motor",
|
||||||
@@ -337,6 +415,18 @@
|
|||||||
"share_csv": "CSV teilen",
|
"share_csv": "CSV teilen",
|
||||||
"export_pdf": "PDF herunterladen",
|
"export_pdf": "PDF herunterladen",
|
||||||
"exporting_pdf": "PDF wird generiert...",
|
"exporting_pdf": "PDF wird generiert...",
|
||||||
|
"ai_summary_title": "KI-Zusammenfassung",
|
||||||
|
"ai_summary_read_only": "Vom Skipper erstellt — nur lesbar für die Crew.",
|
||||||
|
"ai_summary_empty": "Noch keine Zusammenfassung vorhanden.",
|
||||||
|
"ai_summary_generate": "Zusammenfassung generieren",
|
||||||
|
"ai_summary_regenerate": "Neu generieren",
|
||||||
|
"ai_summary_generating": "Wird generiert…",
|
||||||
|
"ai_summary_attempts_remaining": "Noch {{remaining}} von {{max}} Versuchen",
|
||||||
|
"ai_summary_error": "KI-Zusammenfassung fehlgeschlagen. Bitte später erneut versuchen.",
|
||||||
|
"ai_summary_error_no_key": "Kein OpenRouter API-Schlüssel auf dem Server konfiguriert.",
|
||||||
|
"ai_summary_error_rate_limited": "Maximale Anzahl an Generierungen für diesen Reisetag erreicht.",
|
||||||
|
"ai_summary_error_forbidden": "Nur der Skipper darf KI-Zusammenfassungen generieren.",
|
||||||
|
"ai_summary_offline": "Die KI-Zusammenfassung erfordert eine Internetverbindung. Du bist derzeit offline.",
|
||||||
"photos_title": "Foto-Anhänge (E2E-verschlüsselt)",
|
"photos_title": "Foto-Anhänge (E2E-verschlüsselt)",
|
||||||
"photo_caption_label": "Foto-Beschreibung / Label (Optional)",
|
"photo_caption_label": "Foto-Beschreibung / Label (Optional)",
|
||||||
"photo_caption_placeholder": "z.B. Segel setzen nahe Hafeneinfahrt",
|
"photo_caption_placeholder": "z.B. Segel setzen nahe Hafeneinfahrt",
|
||||||
@@ -405,8 +495,8 @@
|
|||||||
"nmea_change_engine_stop": "Motor aus",
|
"nmea_change_engine_stop": "Motor aus",
|
||||||
"nmea_change_autopilot_on": "Autopilot ein",
|
"nmea_change_autopilot_on": "Autopilot ein",
|
||||||
"nmea_change_autopilot_off": "Autopilot aus",
|
"nmea_change_autopilot_off": "Autopilot aus",
|
||||||
"nmea_change_gps_lost": "GPS-Fix verloren",
|
"nmea_change_gps_lost": "GPS-Position verloren",
|
||||||
"nmea_change_gps_regained": "GPS-Fix wiederhergestellt",
|
"nmea_change_gps_regained": "GPS-Position wiederhergestellt",
|
||||||
"nmea_change_water_temp": "Wassertemp. {{from}} → {{to}} °C",
|
"nmea_change_water_temp": "Wassertemp. {{from}} → {{to}} °C",
|
||||||
"nmea_change_departure": "Abfahrt / Fahrtbeginn",
|
"nmea_change_departure": "Abfahrt / Fahrtbeginn",
|
||||||
"nmea_change_anchor": "Ankern / Stop",
|
"nmea_change_anchor": "Ankern / Stop",
|
||||||
@@ -430,7 +520,7 @@
|
|||||||
"new_logbook_placeholder": "Name des Logbuchs oder der Yacht",
|
"new_logbook_placeholder": "Name des Logbuchs oder der Yacht",
|
||||||
"logout": "Abmelden",
|
"logout": "Abmelden",
|
||||||
"logged_in_as": "Angemeldet als {{name}}",
|
"logged_in_as": "Angemeldet als {{name}}",
|
||||||
"delete_confirm": "Bist du sicher, dass du dieses Logbuch unwiderruflich löschen möchtest? Alle lokalen Daten und Server-Kopien werden vernichtet.\n\nTipp: Erstelle vorher unter Einstellungen → Backup & Wiederherstellung eine Sicherungskopie (.daagbok.json), falls du die Daten später behalten möchtest.",
|
"delete_confirm": "Bist du sicher, dass du dieses Logbuch unwiderruflich löschen möchtest? Alle lokalen Daten und Server-Kopien werden vernichtet.\n\nTipp: Erstelle vorher unter Einstellungen → Backup & Wiederherstellung eine Sicherungskopie (.daagbok), falls du die Daten später behalten möchtest.",
|
||||||
"no_logbooks": "Keine Logbücher gefunden. Erstelle dein erstes Logbuch, um zu beginnen!",
|
"no_logbooks": "Keine Logbücher gefunden. Erstelle dein erstes Logbuch, um zu beginnen!",
|
||||||
"loading": "Logbücher werden geladen...",
|
"loading": "Logbücher werden geladen...",
|
||||||
"status_synced": "Synchronisiert",
|
"status_synced": "Synchronisiert",
|
||||||
@@ -446,12 +536,13 @@
|
|||||||
"role_read": "Nur Lesen",
|
"role_read": "Nur Lesen",
|
||||||
"role_read_hint": "Geteiltes Logbuch — nur Ansicht, keine Bearbeitung",
|
"role_read_hint": "Geteiltes Logbuch — nur Ansicht, keine Bearbeitung",
|
||||||
"open_profile": "Profil von {{name}} öffnen",
|
"open_profile": "Profil von {{name}} öffnen",
|
||||||
|
"open_logbook": "Logbuch „{{title}}“ öffnen",
|
||||||
"edit_title": "Logbuch umbenennen",
|
"edit_title": "Logbuch umbenennen",
|
||||||
"edit_placeholder": "Neuer Name des Logbuchs",
|
"edit_placeholder": "Neuer Name des Logbuchs",
|
||||||
"edit_success": "Logbuch erfolgreich umbenannt",
|
"edit_success": "Logbuch erfolgreich umbenannt",
|
||||||
"edit_btn": "Umbenennen",
|
"edit_btn": "Umbenennen",
|
||||||
"filter_label": "Logbücher filtern",
|
"filter_label": "Logbücher filtern",
|
||||||
"filter_placeholder": "Name, Jahr oder Datum …",
|
"filter_placeholder": "Name, Jahr, Datum, Crew oder Schiff …",
|
||||||
"filter_clear": "Filter zurücksetzen",
|
"filter_clear": "Filter zurücksetzen",
|
||||||
"filter_results": "{{count}} Treffer",
|
"filter_results": "{{count}} Treffer",
|
||||||
"filter_no_results": "Keine Logbücher passen zu deiner Suche. Probiere einen anderen Namen oder ein anderes Jahr.",
|
"filter_no_results": "Keine Logbücher passen zu deiner Suche. Probiere einen anderen Namen oder ein anderes Jahr.",
|
||||||
@@ -582,7 +673,72 @@
|
|||||||
"push_unsupported": "Push-Benachrichtigungen werden in diesem Browser nicht unterstützt.",
|
"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_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_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": {
|
"crew": {
|
||||||
"title": "Skipper- & Crew-Profile",
|
"title": "Skipper- & Crew-Profile",
|
||||||
@@ -592,7 +748,7 @@
|
|||||||
"add_crew": "Crew-Mitglied hinzufügen",
|
"add_crew": "Crew-Mitglied hinzufügen",
|
||||||
"edit_crew": "Crew-Mitglied bearbeiten",
|
"edit_crew": "Crew-Mitglied bearbeiten",
|
||||||
"no_crew": "Noch keine Crew-Mitglieder hinzugefügt.",
|
"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",
|
"name": "Name",
|
||||||
"address": "Anschrift",
|
"address": "Anschrift",
|
||||||
"birthdate": "Geburtstag",
|
"birthdate": "Geburtstag",
|
||||||
@@ -643,7 +799,7 @@
|
|||||||
"delete_account_confirm_yes": "Ja, Konto und alle Daten löschen",
|
"delete_account_confirm_yes": "Ja, Konto und alle Daten löschen",
|
||||||
"delete_account_confirm_no": "Abbrechen",
|
"delete_account_confirm_no": "Abbrechen",
|
||||||
"delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuche es erneut.",
|
"delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuche es erneut.",
|
||||||
"delete_backup_hint": "Tipp: Erstelle vor dem Löschen Backups deiner Logbücher (.daagbok.json) in den Einstellungen jedes Logbuchs.",
|
"delete_backup_hint": "Tipp: Erstelle vor dem Löschen Backups deiner Logbücher (.daagbok) in den Einstellungen jedes Logbuchs.",
|
||||||
"deleting_account": "Konto wird gelöscht…",
|
"deleting_account": "Konto wird gelöscht…",
|
||||||
"invite_push_prompt_title": "Push-Benachrichtigungen aktivieren?",
|
"invite_push_prompt_title": "Push-Benachrichtigungen aktivieren?",
|
||||||
"invite_push_prompt_message": "Sobald eingeladene Crewmitglieder Änderungen synchronisieren, kannst du per Push informiert werden. Es werden keine Logbuch-Inhalte im Klartext gesendet.",
|
"invite_push_prompt_message": "Sobald eingeladene Crewmitglieder Änderungen synchronisieren, kannst du per Push informiert werden. Es werden keine Logbuch-Inhalte im Klartext gesendet.",
|
||||||
@@ -652,9 +808,9 @@
|
|||||||
"invite_push_prompt_later": "Später",
|
"invite_push_prompt_later": "Später",
|
||||||
"invite_push_prompt_success": "Push-Benachrichtigungen sind auf diesem Gerät aktiv.",
|
"invite_push_prompt_success": "Push-Benachrichtigungen sind auf diesem Gerät aktiv.",
|
||||||
"backup_title": "Backup & Wiederherstellung",
|
"backup_title": "Backup & Wiederherstellung",
|
||||||
"backup_desc": "Vollständiges verschlüsseltes Backup dieses Logbuchs (Einträge, Fotos, GPS-Tracks, Crew, Schiff). Mit Backup-Passphrase geschützt — für Restore auf diesem oder einem neuen Account.",
|
"backup_desc": "Vollständiges verschlüsseltes Backup dieses Logbuchs (Einträge, Fotos, Sprachnotizen, GPS-Tracks, Crew, Schiff). Mit Backup-Passphrase geschützt — für Restore auf diesem oder einem neuen Account.",
|
||||||
"backup_export_title": "Backup erstellen",
|
"backup_export_title": "Backup erstellen",
|
||||||
"backup_export_desc": "Lädt alle lokalen Daten als .daagbok.json herunter. Bewahre Datei und Passphrase getrennt und sicher auf.",
|
"backup_export_desc": "Lädt alle lokalen Daten als komprimierte .daagbok-Datei herunter. Bewahre Datei und Passphrase getrennt und sicher auf.",
|
||||||
"backup_restore_title": "Backup wiederherstellen",
|
"backup_restore_title": "Backup wiederherstellen",
|
||||||
"backup_restore_desc": "Stellt ein Backup in deinem aktuellen Account wieder her — auch nach Registrierung eines neuen Accounts.",
|
"backup_restore_desc": "Stellt ein Backup in deinem aktuellen Account wieder her — auch nach Registrierung eines neuen Accounts.",
|
||||||
"backup_passphrase": "Backup-Passphrase",
|
"backup_passphrase": "Backup-Passphrase",
|
||||||
@@ -666,7 +822,13 @@
|
|||||||
"backup_export_btn": "Backup herunterladen",
|
"backup_export_btn": "Backup herunterladen",
|
||||||
"backup_exporting": "Backup wird erstellt…",
|
"backup_exporting": "Backup wird erstellt…",
|
||||||
"backup_export_success": "Backup erstellt ({{count}} Reisetage).",
|
"backup_export_success": "Backup erstellt ({{count}} Reisetage).",
|
||||||
"backup_file_label": "Backup-Datei (.daagbok.json)",
|
"backup_file_label": "Backup-Datei (.daagbok)",
|
||||||
|
"backup_export_progress": "Packe Dateien {{current}} / {{total}}…",
|
||||||
|
"backup_invalid_archive": "Die Datei ist kein gültiges Backup-Archiv.",
|
||||||
|
"backup_version_unsupported": "Altes Backup-Format (v1). Bitte ein aktuelles .daagbok-Backup verwenden.",
|
||||||
|
"backup_import_size_confirm": "Dieses Backup ist etwa {{size}} groß. Wiederherstellung kann auf dem Gerät länger dauern und viel Speicher belegen. Fortfahren?",
|
||||||
|
"backup_stat_voice": "{{count}} Sprachnotizen",
|
||||||
|
"backup_stat_size": "Unkomprimiert ca. {{size}}",
|
||||||
"backup_preview_btn": "Inhalt prüfen",
|
"backup_preview_btn": "Inhalt prüfen",
|
||||||
"backup_previewing": "Prüfe…",
|
"backup_previewing": "Prüfe…",
|
||||||
"backup_restore_btn": "Wiederherstellen",
|
"backup_restore_btn": "Wiederherstellen",
|
||||||
@@ -824,7 +986,7 @@
|
|||||||
},
|
},
|
||||||
"welcome_public": {
|
"welcome_public": {
|
||||||
"title": "Willkommen an Bord!",
|
"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": {
|
"nav_logs": {
|
||||||
"title": "Logbucheinträge",
|
"title": "Logbucheinträge",
|
||||||
@@ -843,12 +1005,20 @@
|
|||||||
"body": "Lade GPX-Dateien hoch oder sieh bereits gespeicherte Routen auf der Karte – inklusive Distanz und Geschwindigkeit."
|
"body": "Lade GPX-Dateien hoch oder sieh bereits gespeicherte Routen auf der Karte – inklusive Distanz und Geschwindigkeit."
|
||||||
},
|
},
|
||||||
"nav_vessel": {
|
"nav_vessel": {
|
||||||
"title": "Schiffsdaten",
|
"title": "Schiff fürs Logbuch",
|
||||||
"body": "Hinterlege Name, Maße und technische Daten deiner Yacht – einmal ausfüllen, für alle Reisetage verfügbar."
|
"body": "Wähle aus deiner Schiffsflotte das Schiff für dieses Logbuch. Schiffe pflegst du im Benutzerprofil unter Flotte & Crew."
|
||||||
},
|
},
|
||||||
"nav_crew": {
|
"profile_vessel_pool": {
|
||||||
"title": "Crew-Liste",
|
"title": "Schiffsflotte",
|
||||||
"body": "Verwalte Besatzungsmitglieder und weise sie später Reisetagen zu."
|
"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": {
|
"nav_stats": {
|
||||||
"title": "Statistik-Dashboard",
|
"title": "Statistik-Dashboard",
|
||||||
|
|||||||
+203
-33
@@ -6,6 +6,10 @@
|
|||||||
"beta": "Beta",
|
"beta": "Beta",
|
||||||
"beta_hint": "Beta release — features may still change"
|
"beta_hint": "Beta release — features may still change"
|
||||||
},
|
},
|
||||||
|
"footer": {
|
||||||
|
"kofi_label": "Ko-fi",
|
||||||
|
"kofi_title": "Support the project, development, and running costs on Ko-fi"
|
||||||
|
},
|
||||||
"languages": {
|
"languages": {
|
||||||
"de": "Deutsch",
|
"de": "Deutsch",
|
||||||
"en": "English",
|
"en": "English",
|
||||||
@@ -13,16 +17,29 @@
|
|||||||
"sv": "Svenska",
|
"sv": "Svenska",
|
||||||
"nb": "Norsk"
|
"nb": "Norsk"
|
||||||
},
|
},
|
||||||
|
"dialog": {
|
||||||
|
"ok": "OK",
|
||||||
|
"yes": "Yes",
|
||||||
|
"no": "No"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"load_failed": "Could not load data.",
|
||||||
|
"save_failed": "Could not save changes.",
|
||||||
|
"delete_failed": "Could not delete.",
|
||||||
|
"export_failed": "Export failed."
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"unsaved_changes_title": "Unsaved changes",
|
"unsaved_changes_title": "Unsaved changes",
|
||||||
"unsaved_changes_message": "You have unsaved changes. Leave this page anyway? Your changes will be lost.",
|
"unsaved_changes_message": "You have unsaved changes. Leave this page anyway? Your changes will be lost.",
|
||||||
"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": {
|
"nav": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"vessel": "Vessel Profile",
|
"vessel": "Vessel Profile",
|
||||||
"crew": "Crew List",
|
"crew": "Crew",
|
||||||
"deviation": "Deviation Table",
|
"deviation": "Deviation Table",
|
||||||
"logs": "Logbook Entries",
|
"logs": "Logbook Entries",
|
||||||
"stats": "Statistics",
|
"stats": "Statistics",
|
||||||
@@ -92,13 +109,18 @@
|
|||||||
"update_title": "Update available",
|
"update_title": "Update available",
|
||||||
"update_desc": "A new version of Kapteins Daagbok is ready. Reload to get the latest changes.",
|
"update_desc": "A new version of Kapteins Daagbok is ready. Reload to get the latest changes.",
|
||||||
"update_now": "Reload now",
|
"update_now": "Reload now",
|
||||||
"update_reloading": "Reloading…"
|
"update_reloading": "Reloading…",
|
||||||
|
"storage_persist_hint": "Your browser may delete offline data. Allow persistent storage to keep your logbook safe (browser settings or when prompted)."
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
"status_synced": "Synced",
|
"status_synced": "Synced",
|
||||||
"status_syncing": "Syncing…",
|
"status_syncing": "Syncing…",
|
||||||
"status_offline": "Offline Cache",
|
"status_offline": "Offline Cache",
|
||||||
"status_unsynced": "Unsynced changes"
|
"status_unsynced": "Unsynced changes",
|
||||||
|
"conflict_title": "Sync conflict",
|
||||||
|
"conflict_message": "{{count}} change(s) could not be synced (entry {{id}}…). Choose which version to keep.",
|
||||||
|
"conflict_use_server": "Use server version",
|
||||||
|
"conflict_keep_local": "Keep my version"
|
||||||
},
|
},
|
||||||
"vessel": {
|
"vessel": {
|
||||||
"title": "Vessel Master Data",
|
"title": "Vessel Master Data",
|
||||||
@@ -150,7 +172,8 @@
|
|||||||
"sign_cleared_skipper_re_sign_title": "Skipper signature removed",
|
"sign_cleared_skipper_re_sign_title": "Skipper signature removed",
|
||||||
"sign_cleared_skipper_re_sign": "The event log was changed. The skipper signature was removed. Please sign again.",
|
"sign_cleared_skipper_re_sign": "The event log was changed. The skipper signature was removed. Please sign again.",
|
||||||
"date": "Date",
|
"date": "Date",
|
||||||
"day_of_travel": "Day of Travel",
|
"day_of_travel": "Travel day",
|
||||||
|
"travel_day_number": "Travel day {{number}}",
|
||||||
"departure": "Departure Port (von)",
|
"departure": "Departure Port (von)",
|
||||||
"destination": "Destination Port (nach)",
|
"destination": "Destination Port (nach)",
|
||||||
"route": "Route / Journey",
|
"route": "Route / Journey",
|
||||||
@@ -226,55 +249,84 @@
|
|||||||
"live_sails_confirm": "Log entry",
|
"live_sails_confirm": "Log entry",
|
||||||
"live_sails_confirm_count": "Log entry ({{count}})",
|
"live_sails_confirm_count": "Log entry ({{count}})",
|
||||||
"live_sails": "Sails: {{sails}}",
|
"live_sails": "Sails: {{sails}}",
|
||||||
"live_fix": "Fix",
|
"live_position": "Position",
|
||||||
"live_fix_coords": "Fix {{lat}}, {{lng}}",
|
"live_position_coords": "Position {{lat}}, {{lng}}",
|
||||||
"live_fix_manual_hint": "GPS unavailable. Enter latitude and longitude manually, or try again with the GPS button.",
|
"live_position_manual_hint": "GPS unavailable. Enter latitude and longitude manually, or try again with the GPS button.",
|
||||||
"live_fix_gps_loading": "Getting GPS position…",
|
"live_position_gps_loading": "Getting GPS position…",
|
||||||
"live_fix_invalid": "Please enter valid coordinates (latitude −90…90, longitude −180…180).",
|
"live_position_invalid": "Please enter valid coordinates (latitude −90…90, longitude −180…180).",
|
||||||
"live_fix_lat_placeholder": "Latitude (Lat)",
|
"live_position_lat_placeholder": "Latitude (Lat)",
|
||||||
"live_fix_lng_placeholder": "Longitude (Lng)",
|
"live_position_lng_placeholder": "Longitude (Lng)",
|
||||||
"live_photo_btn": "Photo (camera)",
|
"live_photo_btn": "Photo (camera)",
|
||||||
"live_photo_capture_btn": "Capture",
|
"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_starting": "Starting camera…",
|
||||||
"live_photo_camera_denied": "Camera access denied or unavailable.",
|
"live_photo_camera_denied": "Camera access denied or unavailable.",
|
||||||
"live_photo_camera_unavailable": "Camera is not supported in this browser.",
|
"live_photo_camera_unavailable": "Camera is not supported in this browser.",
|
||||||
|
"live_photo_no_camera": "No camera is available on this device.",
|
||||||
"live_photo_error": "Could not save photo.",
|
"live_photo_error": "Could not save photo.",
|
||||||
"live_photo_entry": "Photo: {{caption}}",
|
"live_photo_entry": "Photo: {{caption}}",
|
||||||
"live_photo_entry_plain": "Photo captured",
|
"live_photo_entry_plain": "Photo captured",
|
||||||
"live_undo_photo_hint": "Photo saved",
|
"live_undo_photo_hint": "Photo saved",
|
||||||
|
"live_voice_btn": "Voice memo",
|
||||||
|
"live_voice_hint": "Record a short voice memo (max. 60 seconds).",
|
||||||
|
"live_voice_record": "Start recording",
|
||||||
|
"live_voice_stop": "Stop recording",
|
||||||
|
"live_voice_recording": "Recording {{time}}",
|
||||||
|
"live_voice_save": "Save",
|
||||||
|
"live_voice_saving": "Saving…",
|
||||||
|
"live_voice_retake": "Record again",
|
||||||
|
"live_voice_mic_denied": "Microphone access denied or unavailable.",
|
||||||
|
"live_voice_record_failed": "Recording failed. Please try again.",
|
||||||
|
"live_voice_unavailable": "Voice memo unavailable",
|
||||||
|
"live_voice_too_large": "Recording is too large. Please record a shorter memo.",
|
||||||
|
"live_voice_error": "Could not save voice memo.",
|
||||||
|
"live_voice_entry": "Voice memo: {{caption}}",
|
||||||
|
"live_voice_entry_plain": "Voice memo",
|
||||||
|
"live_voice_caption_label": "Caption (optional)",
|
||||||
|
"live_voice_caption_placeholder": "e.g. radio call with harbour master",
|
||||||
|
"live_undo_voice_hint": "Voice memo saved",
|
||||||
"live_comment_btn": "Comment",
|
"live_comment_btn": "Comment",
|
||||||
"live_comment_placeholder": "Enter text…",
|
"live_comment_placeholder": "Enter text…",
|
||||||
"live_comment_confirm": "Log entry",
|
"live_comment_confirm": "Log entry",
|
||||||
"live_gps_error": "Could not determine GPS position.",
|
"live_gps_error": "Could not determine GPS position.",
|
||||||
|
"live_gps_start_hint": "Always start your day's voyage with a position.",
|
||||||
"live_event_generic": "Event",
|
"live_event_generic": "Event",
|
||||||
"live_weather_btn": "Weather",
|
"live_weather_btn": "Weather",
|
||||||
"live_weather_owm_btn": "Fetch OpenWeatherMap weather",
|
"live_weather_owm_btn": "Fetch OpenWeatherMap weather",
|
||||||
"live_weather_owm_loading": "Loading 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_position_required": "Log a position first (Position 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_weather_position_stale": "The last position is older than 6 hours. Log a new position before fetching weather.",
|
||||||
"live_wind_btn": "Wind",
|
"live_wind_btn": "Wind",
|
||||||
"live_temp_btn": "Temp °C",
|
"live_temp_btn": "Temp °C",
|
||||||
"live_pressure_btn": "Pressure",
|
"live_pressure_btn": "Pressure",
|
||||||
"live_precip_btn": "Precipitation",
|
"live_precip_btn": "Precipitation",
|
||||||
"live_sea_state_btn": "Sea state",
|
"live_sea_state_btn": "Sea state",
|
||||||
|
"live_visibility_btn": "Visibility",
|
||||||
"live_course_btn": "Course",
|
"live_course_btn": "Course",
|
||||||
"live_fuel_btn": "Fuel",
|
"live_fuel_btn": "+ Fuel",
|
||||||
"live_water_btn": "Water",
|
"live_water_btn": "+ Water",
|
||||||
"live_wind_entry": "Wind {{value}}",
|
"live_wind_entry": "Wind {{value}}",
|
||||||
"live_temp_entry": "Temperature {{temp}} °C",
|
"live_temp_entry": "Temperature {{temp}} °C",
|
||||||
"live_pressure_entry": "Pressure {{value}} hPa",
|
"live_pressure_entry": "Pressure {{value}} hPa",
|
||||||
"live_precip_entry": "Precipitation {{value}}",
|
"live_precip_entry": "Precipitation {{value}}",
|
||||||
"live_sea_state_entry": "Sea state {{value}}",
|
"live_sea_state_entry": "Sea state {{value}}",
|
||||||
|
"live_visibility_entry": "Visibility {{value}}",
|
||||||
"live_course_entry": "Course {{course}}",
|
"live_course_entry": "Course {{course}}",
|
||||||
"live_fuel_entry": "Fuel +{{liters}} L",
|
"live_fuel_entry": "Fuel +{{liters}} L",
|
||||||
"live_water_entry": "Water +{{liters}} L",
|
"live_water_entry": "Water +{{liters}} L",
|
||||||
"live_auto_position": "Auto position",
|
"live_auto_position": "Auto position",
|
||||||
"live_undo_hint": "Entry saved",
|
"live_undo_hint": "Entry saved",
|
||||||
"live_undo_btn": "Undo",
|
"live_undo_btn": "Undo",
|
||||||
|
"live_cancel": "Cancel",
|
||||||
"live_pressure_placeholder": "e.g. 1013",
|
"live_pressure_placeholder": "e.g. 1013",
|
||||||
"live_temp_placeholder": "e.g. 18",
|
"live_temp_placeholder": "e.g. 18",
|
||||||
"live_precip_placeholder": "e.g. light rain",
|
"live_precip_placeholder": "e.g. light rain",
|
||||||
"live_sea_state_placeholder": "e.g. 3",
|
"live_sea_state_placeholder": "e.g. 3",
|
||||||
|
"live_visibility_placeholder": "e.g. 10 km",
|
||||||
"live_course_placeholder": "e.g. 245",
|
"live_course_placeholder": "e.g. 245",
|
||||||
"live_fuel_placeholder": "Liters refilled",
|
"live_fuel_placeholder": "Liters refilled",
|
||||||
"live_water_placeholder": "Liters refilled",
|
"live_water_placeholder": "Liters refilled",
|
||||||
@@ -292,6 +344,7 @@
|
|||||||
"carry_over_tanks_yes": "Carry over",
|
"carry_over_tanks_yes": "Carry over",
|
||||||
"carry_over_tanks_no": "Start at 0",
|
"carry_over_tanks_no": "Start at 0",
|
||||||
"event_title": "Chronological Event Logbook",
|
"event_title": "Chronological Event Logbook",
|
||||||
|
"event_creator": "Entered by",
|
||||||
"no_events": "No events logged for this travel day yet.",
|
"no_events": "No events logged for this travel day yet.",
|
||||||
"event_time": "Time",
|
"event_time": "Time",
|
||||||
"event_mgk": "MgK Course",
|
"event_mgk": "MgK Course",
|
||||||
@@ -316,6 +369,12 @@
|
|||||||
"event_wind_direction": "Wind Dir",
|
"event_wind_direction": "Wind Dir",
|
||||||
"event_wind_strength": "Wind Str",
|
"event_wind_strength": "Wind Str",
|
||||||
"event_sea_state": "Sea State",
|
"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_weather": "Weather",
|
||||||
"event_log": "Log (nm)",
|
"event_log": "Log (nm)",
|
||||||
"event_gps": "GPS Position",
|
"event_gps": "GPS Position",
|
||||||
@@ -323,7 +382,26 @@
|
|||||||
"event_location_placeholder": "e.g. Kiel",
|
"event_location_placeholder": "e.g. Kiel",
|
||||||
"event_remarks": "Remarks / Events",
|
"event_remarks": "Remarks / Events",
|
||||||
"gps_btn": "Get GPS Location",
|
"gps_btn": "Get GPS Location",
|
||||||
|
"gps_permission_denied": "Location access was denied. Allow it in your browser or device settings and try again.",
|
||||||
|
"gps_timeout": "GPS timed out. Try again outdoors with a clear view of the sky.",
|
||||||
|
"gps_position_unavailable": "No GPS signal available. Wait and retry, or enter coordinates manually.",
|
||||||
|
"gps_unavailable": "GPS is not supported by this browser or device.",
|
||||||
|
"gps_failed": "Could not determine GPS position.",
|
||||||
|
"gps_fallback_no_location": "GPS failed. Enter a place under Location / harbour, departure, or destination, or type coordinates manually.",
|
||||||
|
"gps_fallback_success": "Coordinates for \"{{location}}\" resolved from place name (not GPS).",
|
||||||
|
"gps_fallback_failed": "GPS and place-name lookup both failed. Please enter coordinates manually.",
|
||||||
|
"gps_quality_excellent": "Strong GPS reception (±{{accuracy}} m)",
|
||||||
|
"gps_quality_good": "Good GPS reception (±{{accuracy}} m)",
|
||||||
|
"gps_quality_fair": "Fair GPS reception (±{{accuracy}} m) — move outdoors for a better fix.",
|
||||||
|
"gps_quality_poor": "Weak GPS reception (±{{accuracy}} m) — likely few satellites. Retry outdoors or verify the position.",
|
||||||
|
"gps_quality_unknown": "GPS position applied (accuracy not reported by device).",
|
||||||
|
"gps_live_intro_title": "Location for Live Log",
|
||||||
|
"gps_live_intro_body": "The app needs your location for automatic position entries and the GPS button.\n\nTap “Allow location” and confirm in the next dialog. You can always enter a position manually via “Position”.",
|
||||||
|
"gps_live_intro_allow": "Allow location",
|
||||||
|
"gps_live_intro_later": "Later",
|
||||||
|
"gps_enable_in_settings_hint": "Location access is blocked. You can allow it later in your browser or device settings (site / app → Location).",
|
||||||
"weather_btn": "Fetch OpenWeatherMap Weather",
|
"weather_btn": "Fetch OpenWeatherMap Weather",
|
||||||
|
"weather_offline": "OpenWeatherMap requires an internet connection. You are currently offline.",
|
||||||
"event_wind_pressure": "Barometer (hPa)",
|
"event_wind_pressure": "Barometer (hPa)",
|
||||||
"event_heel": "Heel Angle (°)",
|
"event_heel": "Heel Angle (°)",
|
||||||
"event_sails": "Sails / Motor Status",
|
"event_sails": "Sails / Motor Status",
|
||||||
@@ -337,6 +415,18 @@
|
|||||||
"share_csv": "Share CSV",
|
"share_csv": "Share CSV",
|
||||||
"export_pdf": "Download PDF",
|
"export_pdf": "Download PDF",
|
||||||
"exporting_pdf": "Generating PDF...",
|
"exporting_pdf": "Generating PDF...",
|
||||||
|
"ai_summary_title": "AI Summary",
|
||||||
|
"ai_summary_read_only": "Created by the skipper — read-only for crew.",
|
||||||
|
"ai_summary_empty": "No summary yet.",
|
||||||
|
"ai_summary_generate": "Generate summary",
|
||||||
|
"ai_summary_regenerate": "Regenerate",
|
||||||
|
"ai_summary_generating": "Generating…",
|
||||||
|
"ai_summary_attempts_remaining": "{{remaining}} of {{max}} attempts remaining",
|
||||||
|
"ai_summary_error": "AI summary failed. Please try again later.",
|
||||||
|
"ai_summary_error_no_key": "No OpenRouter API key configured on the server.",
|
||||||
|
"ai_summary_error_rate_limited": "Maximum number of generations reached for this travel day.",
|
||||||
|
"ai_summary_error_forbidden": "Only the skipper may generate AI summaries.",
|
||||||
|
"ai_summary_offline": "AI summary generation requires an internet connection. You are currently offline.",
|
||||||
"photos_title": "Photo Attachments (E2E Encrypted)",
|
"photos_title": "Photo Attachments (E2E Encrypted)",
|
||||||
"photo_caption_label": "Photo Caption / Label (Optional)",
|
"photo_caption_label": "Photo Caption / Label (Optional)",
|
||||||
"photo_caption_placeholder": "e.g. Setting sails near harbor entrance",
|
"photo_caption_placeholder": "e.g. Setting sails near harbor entrance",
|
||||||
@@ -405,8 +495,8 @@
|
|||||||
"nmea_change_engine_stop": "Engine off",
|
"nmea_change_engine_stop": "Engine off",
|
||||||
"nmea_change_autopilot_on": "Autopilot on",
|
"nmea_change_autopilot_on": "Autopilot on",
|
||||||
"nmea_change_autopilot_off": "Autopilot off",
|
"nmea_change_autopilot_off": "Autopilot off",
|
||||||
"nmea_change_gps_lost": "GPS fix lost",
|
"nmea_change_gps_lost": "GPS position lost",
|
||||||
"nmea_change_gps_regained": "GPS fix restored",
|
"nmea_change_gps_regained": "GPS position restored",
|
||||||
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
|
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
|
||||||
"nmea_change_departure": "Departure / underway",
|
"nmea_change_departure": "Departure / underway",
|
||||||
"nmea_change_anchor": "Anchored / stop",
|
"nmea_change_anchor": "Anchored / stop",
|
||||||
@@ -430,7 +520,7 @@
|
|||||||
"new_logbook_placeholder": "Logbook or Yacht Name",
|
"new_logbook_placeholder": "Logbook or Yacht Name",
|
||||||
"logout": "Logout",
|
"logout": "Logout",
|
||||||
"logged_in_as": "Signed in as {{name}}",
|
"logged_in_as": "Signed in as {{name}}",
|
||||||
"delete_confirm": "Are you sure you want to permanently delete this logbook? All local data and server copies will be destroyed.\n\nTip: Create a backup first under Settings → Backup & restore (.daagbok.json) if you may need the data later.",
|
"delete_confirm": "Are you sure you want to permanently delete this logbook? All local data and server copies will be destroyed.\n\nTip: Create a backup first under Settings → Backup & restore (.daagbok) if you may need the data later.",
|
||||||
"no_logbooks": "No logbooks found. Create your first logbook to begin!",
|
"no_logbooks": "No logbooks found. Create your first logbook to begin!",
|
||||||
"loading": "Loading logbooks...",
|
"loading": "Loading logbooks...",
|
||||||
"status_synced": "Synced",
|
"status_synced": "Synced",
|
||||||
@@ -446,12 +536,13 @@
|
|||||||
"role_read": "Read only",
|
"role_read": "Read only",
|
||||||
"role_read_hint": "Shared logbook — view only, no editing",
|
"role_read_hint": "Shared logbook — view only, no editing",
|
||||||
"open_profile": "Open profile for {{name}}",
|
"open_profile": "Open profile for {{name}}",
|
||||||
|
"open_logbook": "Open logbook “{{title}}”",
|
||||||
"edit_title": "Rename Logbook",
|
"edit_title": "Rename Logbook",
|
||||||
"edit_placeholder": "New name of the logbook",
|
"edit_placeholder": "New name of the logbook",
|
||||||
"edit_success": "Logbook renamed successfully",
|
"edit_success": "Logbook renamed successfully",
|
||||||
"edit_btn": "Rename",
|
"edit_btn": "Rename",
|
||||||
"filter_label": "Filter logbooks",
|
"filter_label": "Filter logbooks",
|
||||||
"filter_placeholder": "Name, year or date …",
|
"filter_placeholder": "Name, year, date, crew or vessel …",
|
||||||
"filter_clear": "Clear filter",
|
"filter_clear": "Clear filter",
|
||||||
"filter_results": "{{count}} matches",
|
"filter_results": "{{count}} matches",
|
||||||
"filter_no_results": "No logbooks match your search. Try a different name or year.",
|
"filter_no_results": "No logbooks match your search. Try a different name or year.",
|
||||||
@@ -582,7 +673,72 @@
|
|||||||
"push_unsupported": "Push notifications are not supported in this browser.",
|
"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_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_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": {
|
"crew": {
|
||||||
"title": "Skipper & Crew Profiles",
|
"title": "Skipper & Crew Profiles",
|
||||||
@@ -592,7 +748,7 @@
|
|||||||
"add_crew": "Add Crew Member",
|
"add_crew": "Add Crew Member",
|
||||||
"edit_crew": "Edit Crew Member",
|
"edit_crew": "Edit Crew Member",
|
||||||
"no_crew": "No crew members added yet.",
|
"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",
|
"name": "Full Name",
|
||||||
"address": "Address",
|
"address": "Address",
|
||||||
"birthdate": "Date of Birth",
|
"birthdate": "Date of Birth",
|
||||||
@@ -643,7 +799,7 @@
|
|||||||
"delete_account_confirm_yes": "Yes, Delete Account and All Data",
|
"delete_account_confirm_yes": "Yes, Delete Account and All Data",
|
||||||
"delete_account_confirm_no": "Cancel",
|
"delete_account_confirm_no": "Cancel",
|
||||||
"delete_account_failed": "Failed to delete account. Please try again.",
|
"delete_account_failed": "Failed to delete account. Please try again.",
|
||||||
"delete_backup_hint": "Tip: Before deleting, create backups of your logbooks (.daagbok.json) in each logbook's settings.",
|
"delete_backup_hint": "Tip: Before deleting, create backups of your logbooks (.daagbok) in each logbook's settings.",
|
||||||
"deleting_account": "Deleting account…",
|
"deleting_account": "Deleting account…",
|
||||||
"invite_push_prompt_title": "Enable push notifications?",
|
"invite_push_prompt_title": "Enable push notifications?",
|
||||||
"invite_push_prompt_message": "When invited crew members sync changes, you can be notified via push. No logbook content is sent in plain text.",
|
"invite_push_prompt_message": "When invited crew members sync changes, you can be notified via push. No logbook content is sent in plain text.",
|
||||||
@@ -652,9 +808,9 @@
|
|||||||
"invite_push_prompt_later": "Later",
|
"invite_push_prompt_later": "Later",
|
||||||
"invite_push_prompt_success": "Push notifications are active on this device.",
|
"invite_push_prompt_success": "Push notifications are active on this device.",
|
||||||
"backup_title": "Backup & restore",
|
"backup_title": "Backup & restore",
|
||||||
"backup_desc": "Full encrypted backup of this logbook (entries, photos, GPS tracks, crew, vessel). Protected with a backup passphrase — restore on this or a new account.",
|
"backup_desc": "Full encrypted backup of this logbook (entries, photos, voice memos, GPS tracks, crew, vessel). Protected with a backup passphrase — restore on this or a new account.",
|
||||||
"backup_export_title": "Create backup",
|
"backup_export_title": "Create backup",
|
||||||
"backup_export_desc": "Downloads all local data as a .daagbok.json file. Keep the file and passphrase separate and secure.",
|
"backup_export_desc": "Downloads all local data as a compressed .daagbok archive. Keep the file and passphrase separate and secure.",
|
||||||
"backup_restore_title": "Restore backup",
|
"backup_restore_title": "Restore backup",
|
||||||
"backup_restore_desc": "Restores a backup into your current account — including after registering a new account.",
|
"backup_restore_desc": "Restores a backup into your current account — including after registering a new account.",
|
||||||
"backup_passphrase": "Backup passphrase",
|
"backup_passphrase": "Backup passphrase",
|
||||||
@@ -666,7 +822,13 @@
|
|||||||
"backup_export_btn": "Download backup",
|
"backup_export_btn": "Download backup",
|
||||||
"backup_exporting": "Creating backup…",
|
"backup_exporting": "Creating backup…",
|
||||||
"backup_export_success": "Backup created ({{count}} travel days).",
|
"backup_export_success": "Backup created ({{count}} travel days).",
|
||||||
"backup_file_label": "Backup file (.daagbok.json)",
|
"backup_file_label": "Backup file (.daagbok)",
|
||||||
|
"backup_export_progress": "Packing files {{current}} / {{total}}…",
|
||||||
|
"backup_invalid_archive": "The file is not a valid backup archive.",
|
||||||
|
"backup_version_unsupported": "Legacy backup format (v1). Please use a current .daagbok backup.",
|
||||||
|
"backup_import_size_confirm": "This backup is about {{size}} uncompressed. Restore may take longer and use significant memory. Continue?",
|
||||||
|
"backup_stat_voice": "{{count}} voice memos",
|
||||||
|
"backup_stat_size": "Approx. {{size}} uncompressed",
|
||||||
"backup_preview_btn": "Verify contents",
|
"backup_preview_btn": "Verify contents",
|
||||||
"backup_previewing": "Verifying…",
|
"backup_previewing": "Verifying…",
|
||||||
"backup_restore_btn": "Restore",
|
"backup_restore_btn": "Restore",
|
||||||
@@ -824,7 +986,7 @@
|
|||||||
},
|
},
|
||||||
"welcome_public": {
|
"welcome_public": {
|
||||||
"title": "Welcome aboard!",
|
"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": {
|
"nav_logs": {
|
||||||
"title": "Log entries",
|
"title": "Log entries",
|
||||||
@@ -843,12 +1005,20 @@
|
|||||||
"body": "Upload GPX files or view saved routes on the map – including distance and speed stats."
|
"body": "Upload GPX files or view saved routes on the map – including distance and speed stats."
|
||||||
},
|
},
|
||||||
"nav_vessel": {
|
"nav_vessel": {
|
||||||
"title": "Vessel data",
|
"title": "Vessel for logbook",
|
||||||
"body": "Enter your yacht's name, dimensions, and technical details – fill once, use on every travel day."
|
"body": "Choose a vessel from your fleet for this logbook. Manage vessels in your user profile under Fleet & crew."
|
||||||
},
|
},
|
||||||
"nav_crew": {
|
"profile_vessel_pool": {
|
||||||
"title": "Crew list",
|
"title": "Vessel fleet",
|
||||||
"body": "Manage crew members and assign them to travel days later."
|
"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": {
|
"nav_stats": {
|
||||||
"title": "Statistics dashboard",
|
"title": "Statistics dashboard",
|
||||||
|
|||||||
+231
-61
@@ -6,6 +6,10 @@
|
|||||||
"beta": "Beta",
|
"beta": "Beta",
|
||||||
"beta_hint": "Betaversjon - funksjoner kan fortsatt endres"
|
"beta_hint": "Betaversjon - funksjoner kan fortsatt endres"
|
||||||
},
|
},
|
||||||
|
"footer": {
|
||||||
|
"kofi_label": "Ko-fi",
|
||||||
|
"kofi_title": "Støtt prosjektet, videreutvikling og driftskostnader på Ko-fi"
|
||||||
|
},
|
||||||
"languages": {
|
"languages": {
|
||||||
"de": "Deutsch",
|
"de": "Deutsch",
|
||||||
"en": "English",
|
"en": "English",
|
||||||
@@ -13,16 +17,29 @@
|
|||||||
"sv": "Svenska",
|
"sv": "Svenska",
|
||||||
"nb": "Norsk"
|
"nb": "Norsk"
|
||||||
},
|
},
|
||||||
|
"dialog": {
|
||||||
|
"ok": "OK",
|
||||||
|
"yes": "Ja",
|
||||||
|
"no": "Nei"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"load_failed": "Data kunne ikke lastes.",
|
||||||
|
"save_failed": "Endringer kunne ikke lagres.",
|
||||||
|
"delete_failed": "Sletting mislyktes.",
|
||||||
|
"export_failed": "Eksport mislyktes."
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"unsaved_changes_title": "Ikke-lagrede endringer",
|
"unsaved_changes_title": "Ikke-lagrede endringer",
|
||||||
"unsaved_changes_message": "Du har endringer som ikke er lagret. Vil du virkelig forlate siden? Endringene dine vil gå tapt.",
|
"unsaved_changes_message": "Du har endringer som ikke er lagret. Vil du virkelig forlate siden? Endringene dine vil gå tapt.",
|
||||||
"unsaved_changes_leave": "Oppgivelse",
|
"unsaved_changes_stay": "Bli",
|
||||||
"unsaved_changes_stay": "Bli"
|
"unsaved_changes_save_leave": "Lagre og forlat",
|
||||||
|
"unsaved_changes_discard": "Forkast",
|
||||||
|
"unsaved_changes_leave": "Oppgivelse"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Dashbord",
|
"dashboard": "Dashbord",
|
||||||
"vessel": "Skipsdata",
|
"vessel": "Skipsdata",
|
||||||
"crew": "Mannskapsliste",
|
"crew": "Crew",
|
||||||
"deviation": "Tabell over distraksjoner",
|
"deviation": "Tabell over distraksjoner",
|
||||||
"logs": "Loggbokoppføringer",
|
"logs": "Loggbokoppføringer",
|
||||||
"stats": "Statistikk",
|
"stats": "Statistikk",
|
||||||
@@ -92,13 +109,18 @@
|
|||||||
"update_title": "Oppdatering tilgjengelig",
|
"update_title": "Oppdatering tilgjengelig",
|
||||||
"update_desc": "En ny versjon av Kapteins Daagbok er klar. Oppdater for å få med de siste endringene.",
|
"update_desc": "En ny versjon av Kapteins Daagbok er klar. Oppdater for å få med de siste endringene.",
|
||||||
"update_now": "Oppdater nå",
|
"update_now": "Oppdater nå",
|
||||||
"update_reloading": "Laster..."
|
"update_reloading": "Laster...",
|
||||||
|
"storage_persist_hint": "Nettleseren kan slette offlinedata. Tillat permanent lagring slik at loggboken din forblir beskyttet."
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
"status_synced": "Synkronisert",
|
"status_synced": "Synkronisert",
|
||||||
"status_syncing": "Synkroniser...",
|
"status_syncing": "Synkroniser...",
|
||||||
"status_offline": "Frakoblet hurtigbuffer",
|
"status_offline": "Frakoblet hurtigbuffer",
|
||||||
"status_unsynced": "Usynkroniserte endringer"
|
"status_unsynced": "Usynkroniserte endringer",
|
||||||
|
"conflict_title": "Synkroniseringskonflikt",
|
||||||
|
"conflict_message": "{{count}} endring(er) kunne ikke synkroniseres (post {{id}}…). Velg hvilken versjon som skal gjelde.",
|
||||||
|
"conflict_use_server": "Bruk serverversjon",
|
||||||
|
"conflict_keep_local": "Behold min versjon"
|
||||||
},
|
},
|
||||||
"vessel": {
|
"vessel": {
|
||||||
"title": "Stamdata for skip",
|
"title": "Stamdata for skip",
|
||||||
@@ -150,7 +172,8 @@
|
|||||||
"sign_cleared_skipper_re_sign_title": "Skippers signatur fjernet",
|
"sign_cleared_skipper_re_sign_title": "Skippers signatur fjernet",
|
||||||
"sign_cleared_skipper_re_sign": "Hendelsesloggen har blitt endret. Skipperens signatur er fjernet. Vennligst godkjenn på nytt.",
|
"sign_cleared_skipper_re_sign": "Hendelsesloggen har blitt endret. Skipperens signatur er fjernet. Vennligst godkjenn på nytt.",
|
||||||
"date": "dato",
|
"date": "dato",
|
||||||
"day_of_travel": "Reisens dag / reisedag",
|
"day_of_travel": "Reisedag",
|
||||||
|
"travel_day_number": "Reisedag {{number}}",
|
||||||
"departure": "Starthavn (reise fra)",
|
"departure": "Starthavn (reise fra)",
|
||||||
"destination": "Destinasjonsport (til)",
|
"destination": "Destinasjonsport (til)",
|
||||||
"route": "Reise fra/til",
|
"route": "Reise fra/til",
|
||||||
@@ -166,7 +189,7 @@
|
|||||||
"consumption": "Daglig forbruk",
|
"consumption": "Daglig forbruk",
|
||||||
"signatures": "Underskrifter / frigivelse",
|
"signatures": "Underskrifter / frigivelse",
|
||||||
"sign_skipper": "Skippers signatur",
|
"sign_skipper": "Skippers signatur",
|
||||||
"sign_crew": "Mannskapets signatur",
|
"sign_crew": "Crews signatur",
|
||||||
"sign_hint": "Signer med finger, penn eller mus",
|
"sign_hint": "Signer med finger, penn eller mus",
|
||||||
"sign_clear": "Slett",
|
"sign_clear": "Slett",
|
||||||
"sign_export_image": "[Signatur]",
|
"sign_export_image": "[Signatur]",
|
||||||
@@ -186,16 +209,16 @@
|
|||||||
"sign_badge_skipper_title_valid": "Skipper har gitt ut",
|
"sign_badge_skipper_title_valid": "Skipper har gitt ut",
|
||||||
"sign_badge_skipper_title_invalid": "Skippersignaturen er ugyldig - innholdet har blitt endret",
|
"sign_badge_skipper_title_invalid": "Skippersignaturen er ugyldig - innholdet har blitt endret",
|
||||||
"sign_classic_or_passkey": "Valgfritt: klassisk signatur eller Passkey utgivelse ovenfor",
|
"sign_classic_or_passkey": "Valgfritt: klassisk signatur eller Passkey utgivelse ovenfor",
|
||||||
"sign_crew_passkey_hint": "Besetningsmedlemmer med skrivetilgang kan frigjøre via Passkey.",
|
"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_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 mannskap må signere på nytt.",
|
"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 mannskapets signaturer.",
|
"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_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 mannskap må signere på nytt.\n\nØnsker du å fortsette?",
|
"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_proceed": "Skilt",
|
||||||
"sign_cancel": "Avbryt",
|
"sign_cancel": "Avbryt",
|
||||||
"sign_cleared_re_sign_title": "Signaturer fjernet",
|
"sign_cleared_re_sign_title": "Signaturer fjernet",
|
||||||
"sign_cleared_re_sign": "Loggbokoppføringen har blitt endret. Skipperens og mannskapets signaturer er fjernet. Vennligst signer på nytt.",
|
"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!",
|
"no_entries": "Ingen loggbokoppføringer funnet for denne båten. Lag din første seilasdag!",
|
||||||
"back_to_list": "Tilbake til tidsskriftlisten",
|
"back_to_list": "Tilbake til tidsskriftlisten",
|
||||||
"save": "Lagre loggbokside",
|
"save": "Lagre loggbokside",
|
||||||
@@ -226,55 +249,84 @@
|
|||||||
"live_sails_confirm": "Loggfør",
|
"live_sails_confirm": "Loggfør",
|
||||||
"live_sails_confirm_count": "Loggfør ({{count}})",
|
"live_sails_confirm_count": "Loggfør ({{count}})",
|
||||||
"live_sails": "Seil: {{sails}}",
|
"live_sails": "Seil: {{sails}}",
|
||||||
"live_fix": "Fix",
|
"live_position": "Posisjon",
|
||||||
"live_fix_coords": "Fix {{lat}}, {{lng}}",
|
"live_position_coords": "Posisjon {{lat}}, {{lng}}",
|
||||||
"live_fix_manual_hint": "GPS ikke tilgjengelig. Skriv inn bredde- og lengdegrad manuelt, eller prøv igjen med GPS-knappen.",
|
"live_position_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_position_gps_loading": "Henter GPS-posisjon…",
|
||||||
"live_fix_invalid": "Skriv inn gyldige koordinater (bredde −90…90, lengde −180…180).",
|
"live_position_invalid": "Skriv inn gyldige koordinater (bredde −90…90, lengde −180…180).",
|
||||||
"live_fix_lat_placeholder": "Bredde (Lat)",
|
"live_position_lat_placeholder": "Bredde (Lat)",
|
||||||
"live_fix_lng_placeholder": "Lengde (Lng)",
|
"live_position_lng_placeholder": "Lengde (Lng)",
|
||||||
"live_photo_btn": "Foto (kamera)",
|
"live_photo_btn": "Foto (kamera)",
|
||||||
"live_photo_capture_btn": "Ta bilde",
|
"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_starting": "Starter kamera…",
|
||||||
"live_photo_camera_denied": "Kameratilgang nektet eller utilgjengelig.",
|
"live_photo_camera_denied": "Kameratilgang nektet eller utilgjengelig.",
|
||||||
"live_photo_camera_unavailable": "Kamera støttes ikke i denne nettleseren.",
|
"live_photo_camera_unavailable": "Kamera støttes ikke i denne nettleseren.",
|
||||||
|
"live_photo_no_camera": "Ingen kamera er tilgjengelig på denne enheten.",
|
||||||
"live_photo_error": "Kunne ikke lagre foto.",
|
"live_photo_error": "Kunne ikke lagre foto.",
|
||||||
"live_photo_entry": "Foto: {{caption}}",
|
"live_photo_entry": "Foto: {{caption}}",
|
||||||
"live_photo_entry_plain": "Foto tatt",
|
"live_photo_entry_plain": "Foto tatt",
|
||||||
"live_undo_photo_hint": "Foto lagret",
|
"live_undo_photo_hint": "Foto lagret",
|
||||||
|
"live_voice_btn": "Talemelding",
|
||||||
|
"live_voice_hint": "Ta opp en kort talemelding (maks. 60 sekunder).",
|
||||||
|
"live_voice_record": "Start opptak",
|
||||||
|
"live_voice_stop": "Stopp opptak",
|
||||||
|
"live_voice_recording": "Tar opp {{time}}",
|
||||||
|
"live_voice_save": "Lagre",
|
||||||
|
"live_voice_saving": "Lagrer…",
|
||||||
|
"live_voice_retake": "Ta opp på nytt",
|
||||||
|
"live_voice_mic_denied": "Mikrofontilgang nektet eller utilgjengelig.",
|
||||||
|
"live_voice_record_failed": "Opptak mislyktes. Prøv igjen.",
|
||||||
|
"live_voice_unavailable": "Talemelding utilgjengelig",
|
||||||
|
"live_voice_too_large": "Opptaket er for stort. Ta et kortere opptak.",
|
||||||
|
"live_voice_error": "Kunne ikke lagre talemelding.",
|
||||||
|
"live_voice_entry": "Talemelding: {{caption}}",
|
||||||
|
"live_voice_entry_plain": "Talemelding",
|
||||||
|
"live_voice_caption_label": "Bildetekst (valgfritt)",
|
||||||
|
"live_voice_caption_placeholder": "f.eks. radiokontakt med havnesjef",
|
||||||
|
"live_undo_voice_hint": "Talemelding lagret",
|
||||||
"live_comment_btn": "Kommentar",
|
"live_comment_btn": "Kommentar",
|
||||||
"live_comment_placeholder": "Skriv inn tekst…",
|
"live_comment_placeholder": "Skriv inn tekst…",
|
||||||
"live_comment_confirm": "Loggfør",
|
"live_comment_confirm": "Loggfør",
|
||||||
"live_gps_error": "GPS-posisjon kunne ikke bestemmes.",
|
"live_gps_error": "GPS-posisjon kunne ikke bestemmes.",
|
||||||
|
"live_gps_start_hint": "Start alltid dagsreisen med en posisjon.",
|
||||||
"live_event_generic": "Hendelse",
|
"live_event_generic": "Hendelse",
|
||||||
"live_weather_btn": "Vær",
|
"live_weather_btn": "Vær",
|
||||||
"live_weather_owm_btn": "Hent OpenWeatherMap-vær",
|
"live_weather_owm_btn": "Hent OpenWeatherMap-vær",
|
||||||
"live_weather_owm_loading": "Henter 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_position_required": "Logg først en posisjon (Posisjon-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_weather_position_stale": "Siste posisjon er eldre enn 6 timer. Logg en ny posisjon før du henter vær.",
|
||||||
"live_wind_btn": "Vind",
|
"live_wind_btn": "Vind",
|
||||||
"live_temp_btn": "T °C",
|
"live_temp_btn": "T °C",
|
||||||
"live_pressure_btn": "Lufttrykk",
|
"live_pressure_btn": "Lufttrykk",
|
||||||
"live_precip_btn": "Nedbør",
|
"live_precip_btn": "Nedbør",
|
||||||
"live_sea_state_btn": "Sjøgang",
|
"live_sea_state_btn": "Sjøgang",
|
||||||
|
"live_visibility_btn": "Sikt",
|
||||||
"live_course_btn": "Kurs",
|
"live_course_btn": "Kurs",
|
||||||
"live_fuel_btn": "Diesel",
|
"live_fuel_btn": "+ Diesel",
|
||||||
"live_water_btn": "Vann",
|
"live_water_btn": "+ Vann",
|
||||||
"live_wind_entry": "Vind {{value}}",
|
"live_wind_entry": "Vind {{value}}",
|
||||||
"live_temp_entry": "Temperatur {{temp}} °C",
|
"live_temp_entry": "Temperatur {{temp}} °C",
|
||||||
"live_pressure_entry": "Lufttrykk {{value}} hPa",
|
"live_pressure_entry": "Lufttrykk {{value}} hPa",
|
||||||
"live_precip_entry": "Nedbør {{value}}",
|
"live_precip_entry": "Nedbør {{value}}",
|
||||||
"live_sea_state_entry": "Sjøgang {{value}}",
|
"live_sea_state_entry": "Sjøgang {{value}}",
|
||||||
|
"live_visibility_entry": "Sikt {{value}}",
|
||||||
"live_course_entry": "Kurs {{course}}",
|
"live_course_entry": "Kurs {{course}}",
|
||||||
"live_fuel_entry": "Diesel +{{liters}} L",
|
"live_fuel_entry": "Diesel +{{liters}} L",
|
||||||
"live_water_entry": "Vann +{{liters}} L",
|
"live_water_entry": "Vann +{{liters}} L",
|
||||||
"live_auto_position": "Auto-posisjon",
|
"live_auto_position": "Auto-posisjon",
|
||||||
"live_undo_hint": "Oppføring lagret",
|
"live_undo_hint": "Oppføring lagret",
|
||||||
"live_undo_btn": "Angre",
|
"live_undo_btn": "Angre",
|
||||||
|
"live_cancel": "Avbryt",
|
||||||
"live_pressure_placeholder": "f.eks. 1013",
|
"live_pressure_placeholder": "f.eks. 1013",
|
||||||
"live_temp_placeholder": "f.eks. 18",
|
"live_temp_placeholder": "f.eks. 18",
|
||||||
"live_precip_placeholder": "f.eks. lett regn",
|
"live_precip_placeholder": "f.eks. lett regn",
|
||||||
"live_sea_state_placeholder": "f.eks. 3",
|
"live_sea_state_placeholder": "f.eks. 3",
|
||||||
|
"live_visibility_placeholder": "f.eks. 10 km",
|
||||||
"live_course_placeholder": "f.eks. 245",
|
"live_course_placeholder": "f.eks. 245",
|
||||||
"live_fuel_placeholder": "Påfylte liter",
|
"live_fuel_placeholder": "Påfylte liter",
|
||||||
"live_water_placeholder": "Påfylte liter",
|
"live_water_placeholder": "Påfylte liter",
|
||||||
@@ -292,6 +344,7 @@
|
|||||||
"carry_over_tanks_yes": "Ta over",
|
"carry_over_tanks_yes": "Ta over",
|
||||||
"carry_over_tanks_no": "Begynn med 0",
|
"carry_over_tanks_no": "Begynn med 0",
|
||||||
"event_title": "Kronologisk hendelseslogg",
|
"event_title": "Kronologisk hendelseslogg",
|
||||||
|
"event_creator": "Registrert av",
|
||||||
"no_events": "Ingen arrangementer lagt inn for denne reisedagen ennå.",
|
"no_events": "Ingen arrangementer lagt inn for denne reisedagen ennå.",
|
||||||
"event_time": "Tid på døgnet",
|
"event_time": "Tid på døgnet",
|
||||||
"event_mgk": "MgK-kurs",
|
"event_mgk": "MgK-kurs",
|
||||||
@@ -316,6 +369,12 @@
|
|||||||
"event_wind_direction": "Vindretning",
|
"event_wind_direction": "Vindretning",
|
||||||
"event_wind_strength": "Vindstyrke",
|
"event_wind_strength": "Vindstyrke",
|
||||||
"event_sea_state": "Havets tilstand",
|
"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_weather": "Været",
|
||||||
"event_log": "Logg (sm)",
|
"event_log": "Logg (sm)",
|
||||||
"event_gps": "GPS-posisjon",
|
"event_gps": "GPS-posisjon",
|
||||||
@@ -323,7 +382,26 @@
|
|||||||
"event_location_placeholder": "z. f.eks. Kiel",
|
"event_location_placeholder": "z. f.eks. Kiel",
|
||||||
"event_remarks": "Merknader / hendelser",
|
"event_remarks": "Merknader / hendelser",
|
||||||
"gps_btn": "Hent GPS-koordinater",
|
"gps_btn": "Hent GPS-koordinater",
|
||||||
|
"gps_permission_denied": "Tilgang til posisjon ble nektet. Tillat det i nettleser- eller enhetsinnstillinger og prøv igjen.",
|
||||||
|
"gps_timeout": "GPS fikk tidsavbrudd. Prøv igjen utendørs med fri sikt mot himmelen.",
|
||||||
|
"gps_position_unavailable": "Ingen GPS-signal tilgjengelig. Vent og prøv igjen, eller skriv inn koordinater manuelt.",
|
||||||
|
"gps_unavailable": "GPS støttes ikke av denne nettleseren eller enheten.",
|
||||||
|
"gps_failed": "GPS-posisjon kunne ikke bestemmes.",
|
||||||
|
"gps_fallback_no_location": "GPS mislyktes. Skriv inn et sted under sted/havn, avreise eller destinasjon, eller koordinater manuelt.",
|
||||||
|
"gps_fallback_success": "Koordinater for «{{location}}» funnet via stedsnavn (ikke GPS).",
|
||||||
|
"gps_fallback_failed": "GPS og stedsnavnssøk mislyktes. Skriv inn koordinater manuelt.",
|
||||||
|
"gps_quality_excellent": "Sterk GPS-mottak (±{{accuracy}} m)",
|
||||||
|
"gps_quality_good": "God GPS-mottak (±{{accuracy}} m)",
|
||||||
|
"gps_quality_fair": "Middels GPS-mottak (±{{accuracy}} m) – gå utendørs for bedre signal.",
|
||||||
|
"gps_quality_poor": "Svakt GPS-mottak (±{{accuracy}} m) – sannsynligvis få satellitter. Prøv utendørs igjen eller kontroller posisjonen.",
|
||||||
|
"gps_quality_unknown": "GPS-posisjon tatt i bruk (nøyaktighet ikke rapportert av enheten).",
|
||||||
|
"gps_live_intro_title": "Posisjon for live-logg",
|
||||||
|
"gps_live_intro_body": "Appen trenger posisjonen din for automatiske posisjonsregistreringer og GPS-knappen.\n\nTrykk «Tillat posisjon» og bekreft i neste dialog. Du kan alltid legge inn posisjon manuelt via «Posisjon».",
|
||||||
|
"gps_live_intro_allow": "Tillat posisjon",
|
||||||
|
"gps_live_intro_later": "Senere",
|
||||||
|
"gps_enable_in_settings_hint": "Posisjonstilgang er blokkert. Du kan tillate det senere i nettleser- eller enhetsinnstillinger (nettsted / app → Posisjon).",
|
||||||
"weather_btn": "OpenWeatherMap Ring opp været",
|
"weather_btn": "OpenWeatherMap Ring opp været",
|
||||||
|
"weather_offline": "OpenWeatherMap krever internettforbindelse. Du er frakoblet.",
|
||||||
"event_wind_pressure": "Lufttrykk (hPa)",
|
"event_wind_pressure": "Lufttrykk (hPa)",
|
||||||
"event_heel": "Helning (°)",
|
"event_heel": "Helning (°)",
|
||||||
"event_sails": "Seilhåndtering / motor",
|
"event_sails": "Seilhåndtering / motor",
|
||||||
@@ -337,6 +415,18 @@
|
|||||||
"share_csv": "CSV andel",
|
"share_csv": "CSV andel",
|
||||||
"export_pdf": "Last ned PDF",
|
"export_pdf": "Last ned PDF",
|
||||||
"exporting_pdf": "PDF genereres...",
|
"exporting_pdf": "PDF genereres...",
|
||||||
|
"ai_summary_title": "AI-sammendrag",
|
||||||
|
"ai_summary_read_only": "Opprettet av skipperen — kun lesbar for mannskapet.",
|
||||||
|
"ai_summary_empty": "Ingen sammendrag ennå.",
|
||||||
|
"ai_summary_generate": "Generer sammendrag",
|
||||||
|
"ai_summary_regenerate": "Generer på nytt",
|
||||||
|
"ai_summary_generating": "Genererer…",
|
||||||
|
"ai_summary_attempts_remaining": "{{remaining}} av {{max}} forsøk igjen",
|
||||||
|
"ai_summary_error": "AI-sammendrag mislyktes. Prøv igjen senere.",
|
||||||
|
"ai_summary_error_no_key": "Ingen OpenRouter API-nøkkel konfigurert på serveren.",
|
||||||
|
"ai_summary_error_rate_limited": "Maksimalt antall genereringer nådd for denne reisedagen.",
|
||||||
|
"ai_summary_error_forbidden": "Kun skipperen kan generere AI-sammendrag.",
|
||||||
|
"ai_summary_offline": "AI-sammendrag krever internettforbindelse. Du er frakoblet.",
|
||||||
"photos_title": "Bildevedlegg (E2E-kryptert)",
|
"photos_title": "Bildevedlegg (E2E-kryptert)",
|
||||||
"photo_caption_label": "Fotobeskrivelse/etikett (valgfritt)",
|
"photo_caption_label": "Fotobeskrivelse/etikett (valgfritt)",
|
||||||
"photo_caption_placeholder": "f.eks. å sette seil nær innseilingen til havnen",
|
"photo_caption_placeholder": "f.eks. å sette seil nær innseilingen til havnen",
|
||||||
@@ -364,12 +454,12 @@
|
|||||||
"track_map_error": "Kartet kunne ikke lastes inn.",
|
"track_map_error": "Kartet kunne ikke lastes inn.",
|
||||||
"exporting": "Eksport...",
|
"exporting": "Eksport...",
|
||||||
"share_unsupported": "Deling støttes ikke på denne enheten. Filen har blitt lastet ned i stedet.",
|
"share_unsupported": "Deling støttes ikke på denne enheten. Filen har blitt lastet ned i stedet.",
|
||||||
"invite_crew": "Inviter mannskapet",
|
"invite_crew": "Inviter crewet",
|
||||||
"invite_link_copied": "Invitasjonslenke kopiert til utklippstavlen!",
|
"invite_link_copied": "Invitasjonslenke kopiert til utklippstavlen!",
|
||||||
"invite_link_desc": "Del denne lenken med besetningsmedlemmene for å gi dem skrivetilgang til loggboken.",
|
"invite_link_desc": "Del denne lenken med Crew-medlemmene for å gi dem skrivetilgang til loggboken.",
|
||||||
"collaborators_list": "Medlemmer / Besetning",
|
"collaborators_list": "Medlemmer / Crew",
|
||||||
"revoke": "Fjern",
|
"revoke": "Fjern",
|
||||||
"revoke_confirm": "Er du sikker på at du vil oppheve dette besetningsmedlemmets tilgang?",
|
"revoke_confirm": "Er du sikker på at du vil oppheve dette Crew-medlemmets tilgang?",
|
||||||
"invite_role": "Rolle",
|
"invite_role": "Rolle",
|
||||||
"invite_expires": "Lenken er gyldig i 48 timer",
|
"invite_expires": "Lenken er gyldig i 48 timer",
|
||||||
"nmea_import_title": "Import NMEA log",
|
"nmea_import_title": "Import NMEA log",
|
||||||
@@ -415,8 +505,8 @@
|
|||||||
"nmea_change_engine_stop": "Engine off",
|
"nmea_change_engine_stop": "Engine off",
|
||||||
"nmea_change_autopilot_on": "Autopilot on",
|
"nmea_change_autopilot_on": "Autopilot on",
|
||||||
"nmea_change_autopilot_off": "Autopilot off",
|
"nmea_change_autopilot_off": "Autopilot off",
|
||||||
"nmea_change_gps_lost": "GPS fix lost",
|
"nmea_change_gps_lost": "GPS-posisjon tapt",
|
||||||
"nmea_change_gps_regained": "GPS fix restored",
|
"nmea_change_gps_regained": "GPS-posisjon gjenopprettet",
|
||||||
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
|
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
|
||||||
"nmea_change_departure": "Departure / underway",
|
"nmea_change_departure": "Departure / underway",
|
||||||
"nmea_change_anchor": "Anchored / stop",
|
"nmea_change_anchor": "Anchored / stop",
|
||||||
@@ -430,7 +520,7 @@
|
|||||||
"new_logbook_placeholder": "Navn på loggboken eller båten",
|
"new_logbook_placeholder": "Navn på loggboken eller båten",
|
||||||
"logout": "Logg ut",
|
"logout": "Logg ut",
|
||||||
"logged_in_as": "Innlogget som {{name}}",
|
"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.",
|
"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) 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!",
|
"no_logbooks": "Ingen loggbøker funnet. Opprett din første loggbok for å komme i gang!",
|
||||||
"loading": "Loggbøker er lastet...",
|
"loading": "Loggbøker er lastet...",
|
||||||
"status_synced": "Synkronisert",
|
"status_synced": "Synkronisert",
|
||||||
@@ -438,20 +528,21 @@
|
|||||||
"delete_btn": "Slett loggbok",
|
"delete_btn": "Slett loggbok",
|
||||||
"section_owned": "Loggbøkene mine",
|
"section_owned": "Loggbøkene mine",
|
||||||
"section_shared": "Felles loggbøker",
|
"section_shared": "Felles loggbøker",
|
||||||
"section_shared_hint": "Du er invitert som besetningsmedlem. Skipperprofil og innstillinger tilhører eieren.",
|
"section_shared_hint": "Du er invitert som Crew-medlem. Skipperprofil og innstillinger tilhører eieren.",
|
||||||
"role_owner": "Egen loggbok",
|
"role_owner": "Egen loggbok",
|
||||||
"role_owner_hint": "Du er eier og skipper av denne loggboken",
|
"role_owner_hint": "Du er eier og skipper av denne loggboken",
|
||||||
"role_crew": "Tilgang for mannskapet",
|
"role_crew": "Tilgang for crewet",
|
||||||
"role_crew_hint": "Loggbok med invitasjon - du kan jobbe som mannskap og signere den",
|
"role_crew_hint": "Loggbok med invitasjon - du kan jobbe som crew og signere den",
|
||||||
"role_read": "Bare les",
|
"role_read": "Bare les",
|
||||||
"role_read_hint": "Delt loggbok - kun visning, ingen redigering",
|
"role_read_hint": "Delt loggbok - kun visning, ingen redigering",
|
||||||
"open_profile": "Åpne profilen til {{name}}",
|
"open_profile": "Åpne profilen til {{name}}",
|
||||||
|
"open_logbook": "Åpne loggbok «{{title}}»",
|
||||||
"edit_title": "Endre navn på loggbok",
|
"edit_title": "Endre navn på loggbok",
|
||||||
"edit_placeholder": "Nytt navn på loggboken",
|
"edit_placeholder": "Nytt navn på loggboken",
|
||||||
"edit_success": "Loggboken har fått nytt navn",
|
"edit_success": "Loggboken har fått nytt navn",
|
||||||
"edit_btn": "Gi nytt navn",
|
"edit_btn": "Gi nytt navn",
|
||||||
"filter_label": "Filtrer loggbøker",
|
"filter_label": "Filtrer loggbøker",
|
||||||
"filter_placeholder": "Navn, årstall eller dato ...",
|
"filter_placeholder": "Navn, årstall, dato, crew eller skip …",
|
||||||
"filter_clear": "Tilbakestill filter",
|
"filter_clear": "Tilbakestill filter",
|
||||||
"filter_results": "{{count}} Treff",
|
"filter_results": "{{count}} Treff",
|
||||||
"filter_no_results": "Ingen loggbøker samsvarer med søket ditt. Prøv et annet navn eller et annet år.",
|
"filter_no_results": "Ingen loggbøker samsvarer med søket ditt. Prøv et annet navn eller et annet år.",
|
||||||
@@ -576,23 +667,88 @@
|
|||||||
"tour_desc": "La deg veilede gjennom de viktigste områdene i appen på nytt.",
|
"tour_desc": "La deg veilede gjennom de viktigste områdene i appen på nytt.",
|
||||||
"tour_restart": "Start turen på nytt",
|
"tour_restart": "Start turen på nytt",
|
||||||
"push_title": "Push-varsler",
|
"push_title": "Push-varsler",
|
||||||
"push_desc": "Som loggbokseier vil du bli varslet når inviterte besetningsmedlemmer synkroniserer endringer. Ingen innhold overføres i ren tekst.",
|
"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 mannskapet",
|
"push_enable": "Gi oss beskjed om endringer i crewet",
|
||||||
"push_active": "Push-varsler er aktive på denne enheten.",
|
"push_active": "Push-varsler er aktive på denne enheten.",
|
||||||
"push_unsupported": "Push-varsler støttes ikke i denne nettleseren.",
|
"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_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_ios_install_hint": "På iPhone/iPad: Legg til app på startskjermen (iOS 16.4+) for å bruke push.",
|
||||||
"push_error": "Push-varsler kunne ikke aktiveres."
|
"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": {
|
"crew": {
|
||||||
"title": "Skipper- og mannskapsprofiler",
|
"title": "Skipper- og Crew-profiler",
|
||||||
"skipper_section": "Skipperprofil",
|
"skipper_section": "Skipperprofil",
|
||||||
"skipper_read_only_hint": "Skipperprofilen kan bare redigeres av eieren av loggboken.",
|
"skipper_read_only_hint": "Skipperprofilen kan bare redigeres av eieren av loggboken.",
|
||||||
"crew_section": "Mannskapsliste",
|
"crew_section": "Crew-liste",
|
||||||
"add_crew": "Legg til besetningsmedlem",
|
"add_crew": "Legg til Crew-medlem",
|
||||||
"edit_crew": "Rediger besetningsmedlem",
|
"edit_crew": "Rediger Crew-medlem",
|
||||||
"no_crew": "Ingen besetningsmedlemmer er lagt til ennå.",
|
"no_crew": "Ingen Crew-medlemmer er lagt til ennå.",
|
||||||
"max_crew": "Maksimalt antall på 5 besetningsmedlemmer er nådd.",
|
"max_crew": "Maksimalt antall på 12 Crew-medlemmer i poolen er nådd.",
|
||||||
"name": "Navn",
|
"name": "Navn",
|
||||||
"address": "adresse",
|
"address": "adresse",
|
||||||
"birthdate": "Bursdag",
|
"birthdate": "Bursdag",
|
||||||
@@ -605,8 +761,8 @@
|
|||||||
"save": "Lagre skipperdata",
|
"save": "Lagre skipperdata",
|
||||||
"save_member": "Lagre medlem",
|
"save_member": "Lagre medlem",
|
||||||
"saved": "Skipperprofilen er vellykket lagret!",
|
"saved": "Skipperprofilen er vellykket lagret!",
|
||||||
"loading": "Mannskapsfilene er lastet inn...",
|
"loading": "Crew-filene er lastet inn...",
|
||||||
"delete_confirm": "Er du sikker på at du vil fjerne dette besetningsmedlemmet?"
|
"delete_confirm": "Er du sikker på at du vil fjerne dette Crew-medlemmet?"
|
||||||
},
|
},
|
||||||
"deviation": {
|
"deviation": {
|
||||||
"title": "Tabell over kompassavvik",
|
"title": "Tabell over kompassavvik",
|
||||||
@@ -628,7 +784,7 @@
|
|||||||
"weather_date_mismatch": "Værdata kan bare hentes ut for i dag ({{today}}). Denne loggbokoppføringen er datert {{date}}.",
|
"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.",
|
"gps_error": "Vennligst skriv inn en posisjon eller finn GPS-koordinatene.",
|
||||||
"share_title": "Del loggbok (skrivebeskyttet)",
|
"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 mannskapet ditt. Krypteringsnøklene overføres aldri til serveren (de forblir i hash-delen av URL-en).",
|
"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_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_enable": "Aktiver offentlig lenke",
|
||||||
"share_copied": "Linken er kopiert!",
|
"share_copied": "Linken er kopiert!",
|
||||||
@@ -636,25 +792,25 @@
|
|||||||
"link_qr_hint": "Skann QR-koden med telefonen",
|
"link_qr_hint": "Skann QR-koden med telefonen",
|
||||||
"link_qr_alt": "QR-kode for lenken",
|
"link_qr_alt": "QR-kode for lenken",
|
||||||
"danger_zone_title": "Faresone",
|
"danger_zone_title": "Faresone",
|
||||||
"danger_zone_desc": "Hvis du sletter kontoen din, slettes alle dine Passkeys, loggbøker, skipsdata, mannskapsprofiler, reiseoppføringer og E2E-nøkler ugjenkallelig. Denne handlingen kan ikke angres.",
|
"danger_zone_desc": "Hvis du sletter kontoen din, slettes alle dine Passkeys, loggbøker, skipsdata, Crew-profiler, reiseoppføringer og E2E-nøkler ugjenkallelig. Denne handlingen kan ikke angres.",
|
||||||
"delete_account_btn": "Slett konto ugjenkallelig",
|
"delete_account_btn": "Slett konto ugjenkallelig",
|
||||||
"delete_account_confirm_title": "Slett konto?",
|
"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_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_yes": "Ja, slett konto og alle data",
|
||||||
"delete_account_confirm_no": "Avbryt",
|
"delete_account_confirm_no": "Avbryt",
|
||||||
"delete_account_failed": "Kontoen kunne ikke slettes. Vennligst prøv igjen.",
|
"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.",
|
"delete_backup_hint": "Tips: Lag sikkerhetskopier av loggbøkene dine (.daagbok) i innstillingene for hver loggbok før du sletter dem.",
|
||||||
"deleting_account": "Kontoen vil bli slettet...",
|
"deleting_account": "Kontoen vil bli slettet...",
|
||||||
"invite_push_prompt_title": "Aktivere push-varsler?",
|
"invite_push_prompt_title": "Aktivere push-varsler?",
|
||||||
"invite_push_prompt_message": "Så snart inviterte besetningsmedlemmer synkroniserer endringer, kan du bli informert via push. Ingen loggbokinnhold sendes i ren tekst.",
|
"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 besetningsmedlemmene 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_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_enable": "Aktiver nå",
|
||||||
"invite_push_prompt_later": "Senere",
|
"invite_push_prompt_later": "Senere",
|
||||||
"invite_push_prompt_success": "Push-varsler er aktive på denne enheten.",
|
"invite_push_prompt_success": "Push-varsler er aktive på denne enheten.",
|
||||||
"backup_title": "Sikkerhetskopiering og gjenoppretting",
|
"backup_title": "Sikkerhetskopiering og gjenoppretting",
|
||||||
"backup_desc": "Fullstendig kryptert sikkerhetskopi av denne loggboken (oppføringer, bilder, GPS-spor, mannskap, skip). Beskyttet med sikkerhetskopieringspassord - for gjenoppretting til denne eller en ny konto.",
|
"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_title": "Opprett sikkerhetskopi",
|
||||||
"backup_export_desc": "Laster ned alle lokale data som .daagbok.json. Hold filen og passordfrasen adskilt og sikker.",
|
"backup_export_desc": "Laster ned alle lokale data som et komprimert .daagbok-arkiv. Hold filen og passordfrasen adskilt og sikker.",
|
||||||
"backup_restore_title": "Gjenopprett sikkerhetskopi",
|
"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_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": "Passord for sikkerhetskopiering",
|
||||||
@@ -666,7 +822,13 @@
|
|||||||
"backup_export_btn": "Last ned sikkerhetskopi",
|
"backup_export_btn": "Last ned sikkerhetskopi",
|
||||||
"backup_exporting": "Sikkerhetskopien er opprettet...",
|
"backup_exporting": "Sikkerhetskopien er opprettet...",
|
||||||
"backup_export_success": "Sikkerhetskopi opprettet ({{count}} reisedager).",
|
"backup_export_success": "Sikkerhetskopi opprettet ({{count}} reisedager).",
|
||||||
"backup_file_label": "Sikkerhetskopifil (.daagbok.json)",
|
"backup_file_label": "Sikkerhetskopifil (.daagbok)",
|
||||||
|
"backup_export_progress": "Pakker filer {{current}} / {{total}}…",
|
||||||
|
"backup_invalid_archive": "Filen er ikke et gyldig backup-arkiv.",
|
||||||
|
"backup_version_unsupported": "Gammelt backup-format (v1). Bruk en aktuell .daagbok-sikkerhetskopi.",
|
||||||
|
"backup_import_size_confirm": "Denne sikkerhetskopien er ca. {{size}} ukomprimert. Gjenoppretting kan ta lengre tid. Fortsette?",
|
||||||
|
"backup_stat_voice": "{{count}} talemeldinger",
|
||||||
|
"backup_stat_size": "Ca. {{size}} ukomprimert",
|
||||||
"backup_preview_btn": "Sjekk innhold",
|
"backup_preview_btn": "Sjekk innhold",
|
||||||
"backup_previewing": "Sjekk...",
|
"backup_previewing": "Sjekk...",
|
||||||
"backup_restore_btn": "Gjenopprett",
|
"backup_restore_btn": "Gjenopprett",
|
||||||
@@ -682,7 +844,7 @@
|
|||||||
"backup_new_id_confirm": "Importere sikkerhetskopien som en ny loggbok med ny ID?",
|
"backup_new_id_confirm": "Importere sikkerhetskopien som en ny loggbok med ny ID?",
|
||||||
"backup_stat_entries": "{{count}} Reisedager",
|
"backup_stat_entries": "{{count}} Reisedager",
|
||||||
"backup_stat_photos": "{{count}} Bilder",
|
"backup_stat_photos": "{{count}} Bilder",
|
||||||
"backup_stat_crew": "{{count}} Mannskapsposter",
|
"backup_stat_crew": "{{count}} Crew-poster",
|
||||||
"backup_stat_tracks": "{{count}} GPS-spor",
|
"backup_stat_tracks": "{{count}} GPS-spor",
|
||||||
"backup_exported_at": "Eksportert: {{date}}"
|
"backup_exported_at": "Eksportert: {{date}}"
|
||||||
},
|
},
|
||||||
@@ -824,7 +986,7 @@
|
|||||||
},
|
},
|
||||||
"welcome_public": {
|
"welcome_public": {
|
||||||
"title": "Velkommen om bord!",
|
"title": "Velkommen om bord!",
|
||||||
"body": "Utforsk vår demologgbok med tre dagers reise i Kielfjorden - uten konto. Denne korte omvisningen viser deg skipsdata, mannskap og loggbokoppføringer."
|
"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": {
|
"nav_logs": {
|
||||||
"title": "Loggbokoppføringer",
|
"title": "Loggbokoppføringer",
|
||||||
@@ -843,12 +1005,20 @@
|
|||||||
"body": "Last opp GPX-filer eller se allerede lagrede ruter på kartet - inkludert avstand og hastighet."
|
"body": "Last opp GPX-filer eller se allerede lagrede ruter på kartet - inkludert avstand og hastighet."
|
||||||
},
|
},
|
||||||
"nav_vessel": {
|
"nav_vessel": {
|
||||||
"title": "Skipsdata",
|
"title": "Skip for loggbok",
|
||||||
"body": "Skriv inn navn, dimensjoner og tekniske data for båten din - fyll inn én gang, tilgjengelig for alle reisedager."
|
"body": "Velg skip fra flåten for denne loggboken. Administrer skip i brukerprofilen under Flåte og crew."
|
||||||
},
|
},
|
||||||
"nav_crew": {
|
"profile_vessel_pool": {
|
||||||
"title": "Mannskapsliste",
|
"title": "Skipsflåte",
|
||||||
"body": "Administrer mannskapet og tilordne dem til reisedager senere."
|
"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": {
|
"nav_stats": {
|
||||||
"title": "Dashbord for statistikk",
|
"title": "Dashbord for statistikk",
|
||||||
@@ -874,7 +1044,7 @@
|
|||||||
},
|
},
|
||||||
"seo": {
|
"seo": {
|
||||||
"title": "Kapteins Daagbok - Gratis digital loggbok for fritidsbåter (uten reklame)",
|
"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, mannskaps- og skipsdata på en sikker måte - også offline som PWA.",
|
"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",
|
"keywords": "Yachtloggbok, skipsloggbok, loggbok om bord, seiling, Passkey, E2E-kryptering, GPS-sporing, maritim loggbok, gratis, reklamefri, gratis, uten reklame",
|
||||||
"ogImageAlt": "Kapteins Daagbok Logo"
|
"ogImageAlt": "Kapteins Daagbok Logo"
|
||||||
}
|
}
|
||||||
|
|||||||
+232
-62
@@ -6,6 +6,10 @@
|
|||||||
"beta": "Beta",
|
"beta": "Beta",
|
||||||
"beta_hint": "Betaversion - funktioner kan fortfarande ändras"
|
"beta_hint": "Betaversion - funktioner kan fortfarande ändras"
|
||||||
},
|
},
|
||||||
|
"footer": {
|
||||||
|
"kofi_label": "Ko-fi",
|
||||||
|
"kofi_title": "Stöd projektet, vidareutveckling och driftskostnader på Ko-fi"
|
||||||
|
},
|
||||||
"languages": {
|
"languages": {
|
||||||
"de": "Deutsch",
|
"de": "Deutsch",
|
||||||
"en": "English",
|
"en": "English",
|
||||||
@@ -13,16 +17,29 @@
|
|||||||
"sv": "Svenska",
|
"sv": "Svenska",
|
||||||
"nb": "Norsk"
|
"nb": "Norsk"
|
||||||
},
|
},
|
||||||
|
"dialog": {
|
||||||
|
"ok": "OK",
|
||||||
|
"yes": "Ja",
|
||||||
|
"no": "Nej"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"load_failed": "Data kunde inte laddas.",
|
||||||
|
"save_failed": "Ändringar kunde inte sparas.",
|
||||||
|
"delete_failed": "Radering misslyckades.",
|
||||||
|
"export_failed": "Export misslyckades."
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"unsaved_changes_title": "Osparade ändringar",
|
"unsaved_changes_title": "Osparade ändringar",
|
||||||
"unsaved_changes_message": "Du har ändringar som inte sparats. Vill du verkligen lämna sidan? Dina ändringar kommer att gå förlorade.",
|
"unsaved_changes_message": "Du har ändringar som inte sparats. Vill du verkligen lämna sidan? Dina ändringar kommer att gå förlorade.",
|
||||||
"unsaved_changes_leave": "Övergivande",
|
"unsaved_changes_stay": "Stanna kvar",
|
||||||
"unsaved_changes_stay": "Stanna kvar"
|
"unsaved_changes_save_leave": "Spara och lämna",
|
||||||
|
"unsaved_changes_discard": "Kasta",
|
||||||
|
"unsaved_changes_leave": "Övergivande"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Instrumentpanel",
|
"dashboard": "Instrumentpanel",
|
||||||
"vessel": "Fartygsdata",
|
"vessel": "Fartygsdata",
|
||||||
"crew": "Besättningslista",
|
"crew": "Crew",
|
||||||
"deviation": "Distraktionsbord",
|
"deviation": "Distraktionsbord",
|
||||||
"logs": "Loggboksanteckningar",
|
"logs": "Loggboksanteckningar",
|
||||||
"stats": "Statistik",
|
"stats": "Statistik",
|
||||||
@@ -92,13 +109,18 @@
|
|||||||
"update_title": "Uppdatering tillgänglig",
|
"update_title": "Uppdatering tillgänglig",
|
||||||
"update_desc": "En ny version av Kapteins Daagbok är klar. Uppdatera för att få de senaste ändringarna.",
|
"update_desc": "En ny version av Kapteins Daagbok är klar. Uppdatera för att få de senaste ändringarna.",
|
||||||
"update_now": "Uppdatering nu",
|
"update_now": "Uppdatering nu",
|
||||||
"update_reloading": "Laddar..."
|
"update_reloading": "Laddar...",
|
||||||
|
"storage_persist_hint": "Webbläsaren kan radera offlinedata. Tillåt permanent lagring så att din loggbok förblir skyddad."
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
"status_synced": "Synkroniserad",
|
"status_synced": "Synkroniserad",
|
||||||
"status_syncing": "Synkronisera...",
|
"status_syncing": "Synkronisera...",
|
||||||
"status_offline": "Offline-cache",
|
"status_offline": "Offline-cache",
|
||||||
"status_unsynced": "Osynkroniserade förändringar"
|
"status_unsynced": "Osynkroniserade förändringar",
|
||||||
|
"conflict_title": "Synkroniseringskonflikt",
|
||||||
|
"conflict_message": "{{count}} ändring(ar) kunde inte synkas (post {{id}}…). Välj vilken version som ska gälla.",
|
||||||
|
"conflict_use_server": "Använd serverversion",
|
||||||
|
"conflict_keep_local": "Behåll min version"
|
||||||
},
|
},
|
||||||
"vessel": {
|
"vessel": {
|
||||||
"title": "Masterdata för fartyg",
|
"title": "Masterdata för fartyg",
|
||||||
@@ -150,7 +172,8 @@
|
|||||||
"sign_cleared_skipper_re_sign_title": "Skippers signatur borttagen",
|
"sign_cleared_skipper_re_sign_title": "Skippers signatur borttagen",
|
||||||
"sign_cleared_skipper_re_sign": "Händelseloggen har ändrats. Skepparens signatur har tagits bort. Vänligen godkänn igen.",
|
"sign_cleared_skipper_re_sign": "Händelseloggen har ändrats. Skepparens signatur har tagits bort. Vänligen godkänn igen.",
|
||||||
"date": "datum",
|
"date": "datum",
|
||||||
"day_of_travel": "Resedag / resedag",
|
"day_of_travel": "Resedag",
|
||||||
|
"travel_day_number": "Resedag {{number}}",
|
||||||
"departure": "Starthamn (resa från)",
|
"departure": "Starthamn (resa från)",
|
||||||
"destination": "Destinationsport (till)",
|
"destination": "Destinationsport (till)",
|
||||||
"route": "Resa från/till",
|
"route": "Resa från/till",
|
||||||
@@ -166,7 +189,7 @@
|
|||||||
"consumption": "Daglig konsumtion",
|
"consumption": "Daglig konsumtion",
|
||||||
"signatures": "Underskrifter / frisläppande",
|
"signatures": "Underskrifter / frisläppande",
|
||||||
"sign_skipper": "Skepparens signatur",
|
"sign_skipper": "Skepparens signatur",
|
||||||
"sign_crew": "Besättningens signatur",
|
"sign_crew": "Crews signatur",
|
||||||
"sign_hint": "Signera med finger, penna eller mus",
|
"sign_hint": "Signera med finger, penna eller mus",
|
||||||
"sign_clear": "Radera",
|
"sign_clear": "Radera",
|
||||||
"sign_export_image": "[Signatur]",
|
"sign_export_image": "[Signatur]",
|
||||||
@@ -186,16 +209,16 @@
|
|||||||
"sign_badge_skipper_title_valid": "Skepparen har släppt",
|
"sign_badge_skipper_title_valid": "Skepparen har släppt",
|
||||||
"sign_badge_skipper_title_invalid": "Skippers signatur ogiltig - innehållet har ändrats",
|
"sign_badge_skipper_title_invalid": "Skippers signatur ogiltig - innehållet har ändrats",
|
||||||
"sign_classic_or_passkey": "Valfritt: klassisk signatur eller Passkey release ovan",
|
"sign_classic_or_passkey": "Valfritt: klassisk signatur eller Passkey release ovan",
|
||||||
"sign_crew_passkey_hint": "Besättningsmedlemmar med skrivbehörighet kan frigöra via Passkey.",
|
"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_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 besättningen måste underteckna på nytt.",
|
"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 besättningens signaturer.",
|
"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_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 besättningen måste underteckna på nytt.\n\nVill du fortsätta?",
|
"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_proceed": "Teckna",
|
||||||
"sign_cancel": "Avbryt",
|
"sign_cancel": "Avbryt",
|
||||||
"sign_cleared_re_sign_title": "Underskrifter borttagna",
|
"sign_cleared_re_sign_title": "Underskrifter borttagna",
|
||||||
"sign_cleared_re_sign": "Loggboksanteckningen har ändrats. Skepparens och besättningens namnteckningar har tagits bort. Vänligen underteckna igen.",
|
"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!",
|
"no_entries": "Inga loggboksposter hittade för denna yacht. Skapa din första resedag!",
|
||||||
"back_to_list": "Tillbaka till tidskriftslistan",
|
"back_to_list": "Tillbaka till tidskriftslistan",
|
||||||
"save": "Spara loggbokssida",
|
"save": "Spara loggbokssida",
|
||||||
@@ -226,55 +249,84 @@
|
|||||||
"live_sails_confirm": "Logga",
|
"live_sails_confirm": "Logga",
|
||||||
"live_sails_confirm_count": "Logga ({{count}})",
|
"live_sails_confirm_count": "Logga ({{count}})",
|
||||||
"live_sails": "Segel: {{sails}}",
|
"live_sails": "Segel: {{sails}}",
|
||||||
"live_fix": "Fix",
|
"live_position": "Position",
|
||||||
"live_fix_coords": "Fix {{lat}}, {{lng}}",
|
"live_position_coords": "Position {{lat}}, {{lng}}",
|
||||||
"live_fix_manual_hint": "GPS ej tillgänglig. Ange latitud och longitud manuellt, eller försök igen med GPS-knappen.",
|
"live_position_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_position_gps_loading": "Hämtar GPS-position…",
|
||||||
"live_fix_invalid": "Ange giltiga koordinater (latitud −90…90, longitud −180…180).",
|
"live_position_invalid": "Ange giltiga koordinater (latitud −90…90, longitud −180…180).",
|
||||||
"live_fix_lat_placeholder": "Latitud (Lat)",
|
"live_position_lat_placeholder": "Latitud (Lat)",
|
||||||
"live_fix_lng_placeholder": "Longitud (Lng)",
|
"live_position_lng_placeholder": "Longitud (Lng)",
|
||||||
"live_photo_btn": "Foto (kamera)",
|
"live_photo_btn": "Foto (kamera)",
|
||||||
"live_photo_capture_btn": "Ta foto",
|
"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_starting": "Startar kamera…",
|
||||||
"live_photo_camera_denied": "Kameraåtkomst nekad eller ej tillgänglig.",
|
"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_camera_unavailable": "Kameran stöds inte i den här webbläsaren.",
|
||||||
|
"live_photo_no_camera": "Ingen kamera finns på den här enheten.",
|
||||||
"live_photo_error": "Foto kunde inte sparas.",
|
"live_photo_error": "Foto kunde inte sparas.",
|
||||||
"live_photo_entry": "Foto: {{caption}}",
|
"live_photo_entry": "Foto: {{caption}}",
|
||||||
"live_photo_entry_plain": "Foto taget",
|
"live_photo_entry_plain": "Foto taget",
|
||||||
"live_undo_photo_hint": "Foto sparat",
|
"live_undo_photo_hint": "Foto sparat",
|
||||||
|
"live_voice_btn": "Röstanteckning",
|
||||||
|
"live_voice_hint": "Spela in en kort röstanteckning (max 60 sekunder).",
|
||||||
|
"live_voice_record": "Starta inspelning",
|
||||||
|
"live_voice_stop": "Stoppa inspelning",
|
||||||
|
"live_voice_recording": "Spelar in {{time}}",
|
||||||
|
"live_voice_save": "Spara",
|
||||||
|
"live_voice_saving": "Sparar…",
|
||||||
|
"live_voice_retake": "Spela in igen",
|
||||||
|
"live_voice_mic_denied": "Mikrofonåtkomst nekad eller ej tillgänglig.",
|
||||||
|
"live_voice_record_failed": "Inspelning misslyckades. Försök igen.",
|
||||||
|
"live_voice_unavailable": "Röstanteckning ej tillgänglig",
|
||||||
|
"live_voice_too_large": "Inspelningen är för stor. Spela in kortare.",
|
||||||
|
"live_voice_error": "Kunde inte spara röstanteckning.",
|
||||||
|
"live_voice_entry": "Röstanteckning: {{caption}}",
|
||||||
|
"live_voice_entry_plain": "Röstanteckning",
|
||||||
|
"live_voice_caption_label": "Bildtext (valfritt)",
|
||||||
|
"live_voice_caption_placeholder": "t.ex. radiokontakt med hamnmästare",
|
||||||
|
"live_undo_voice_hint": "Röstanteckning sparad",
|
||||||
"live_comment_btn": "Kommentar",
|
"live_comment_btn": "Kommentar",
|
||||||
"live_comment_placeholder": "Ange text…",
|
"live_comment_placeholder": "Ange text…",
|
||||||
"live_comment_confirm": "Logga",
|
"live_comment_confirm": "Logga",
|
||||||
"live_gps_error": "GPS-position kunde inte bestämmas.",
|
"live_gps_error": "GPS-position kunde inte bestämmas.",
|
||||||
|
"live_gps_start_hint": "Börja alltid dagsresan med en position.",
|
||||||
"live_event_generic": "Händelse",
|
"live_event_generic": "Händelse",
|
||||||
"live_weather_btn": "Väder",
|
"live_weather_btn": "Väder",
|
||||||
"live_weather_owm_btn": "Hämta OpenWeatherMap-väder",
|
"live_weather_owm_btn": "Hämta OpenWeatherMap-väder",
|
||||||
"live_weather_owm_loading": "Hämtar 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_position_required": "Logga först en position (Position-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_weather_position_stale": "Senaste positionen är äldre än 6 timmar. Logga en ny position innan du hämtar väder.",
|
||||||
"live_wind_btn": "Vind",
|
"live_wind_btn": "Vind",
|
||||||
"live_temp_btn": "T °C",
|
"live_temp_btn": "T °C",
|
||||||
"live_pressure_btn": "Lufttryck",
|
"live_pressure_btn": "Lufttryck",
|
||||||
"live_precip_btn": "Nederbörd",
|
"live_precip_btn": "Nederbörd",
|
||||||
"live_sea_state_btn": "Sjögang",
|
"live_sea_state_btn": "Sjögang",
|
||||||
|
"live_visibility_btn": "Sikt",
|
||||||
"live_course_btn": "Kurs",
|
"live_course_btn": "Kurs",
|
||||||
"live_fuel_btn": "Diesel",
|
"live_fuel_btn": "+ Diesel",
|
||||||
"live_water_btn": "Vatten",
|
"live_water_btn": "+ Vatten",
|
||||||
"live_wind_entry": "Vind {{value}}",
|
"live_wind_entry": "Vind {{value}}",
|
||||||
"live_temp_entry": "Temperatur {{temp}} °C",
|
"live_temp_entry": "Temperatur {{temp}} °C",
|
||||||
"live_pressure_entry": "Lufttryck {{value}} hPa",
|
"live_pressure_entry": "Lufttryck {{value}} hPa",
|
||||||
"live_precip_entry": "Nederbörd {{value}}",
|
"live_precip_entry": "Nederbörd {{value}}",
|
||||||
"live_sea_state_entry": "Sjögang {{value}}",
|
"live_sea_state_entry": "Sjögang {{value}}",
|
||||||
|
"live_visibility_entry": "Sikt {{value}}",
|
||||||
"live_course_entry": "Kurs {{course}}",
|
"live_course_entry": "Kurs {{course}}",
|
||||||
"live_fuel_entry": "Diesel +{{liters}} L",
|
"live_fuel_entry": "Diesel +{{liters}} L",
|
||||||
"live_water_entry": "Vatten +{{liters}} L",
|
"live_water_entry": "Vatten +{{liters}} L",
|
||||||
"live_auto_position": "Auto-position",
|
"live_auto_position": "Auto-position",
|
||||||
"live_undo_hint": "Post sparad",
|
"live_undo_hint": "Post sparad",
|
||||||
"live_undo_btn": "Ångra",
|
"live_undo_btn": "Ångra",
|
||||||
|
"live_cancel": "Avbryt",
|
||||||
"live_pressure_placeholder": "t.ex. 1013",
|
"live_pressure_placeholder": "t.ex. 1013",
|
||||||
"live_temp_placeholder": "t.ex. 18",
|
"live_temp_placeholder": "t.ex. 18",
|
||||||
"live_precip_placeholder": "t.ex. lätt regn",
|
"live_precip_placeholder": "t.ex. lätt regn",
|
||||||
"live_sea_state_placeholder": "t.ex. 3",
|
"live_sea_state_placeholder": "t.ex. 3",
|
||||||
|
"live_visibility_placeholder": "t.ex. 10 km",
|
||||||
"live_course_placeholder": "t.ex. 245",
|
"live_course_placeholder": "t.ex. 245",
|
||||||
"live_fuel_placeholder": "Påfyllda liter",
|
"live_fuel_placeholder": "Påfyllda liter",
|
||||||
"live_water_placeholder": "Påfyllda liter",
|
"live_water_placeholder": "Påfyllda liter",
|
||||||
@@ -292,6 +344,7 @@
|
|||||||
"carry_over_tanks_yes": "Ta över",
|
"carry_over_tanks_yes": "Ta över",
|
||||||
"carry_over_tanks_no": "Börja med 0",
|
"carry_over_tanks_no": "Börja med 0",
|
||||||
"event_title": "Kronologisk händelselogg",
|
"event_title": "Kronologisk händelselogg",
|
||||||
|
"event_creator": "Registrerad av",
|
||||||
"no_events": "Inga händelser inlagda för denna resdag ännu.",
|
"no_events": "Inga händelser inlagda för denna resdag ännu.",
|
||||||
"event_time": "Tid på dygnet",
|
"event_time": "Tid på dygnet",
|
||||||
"event_mgk": "MgK-kurs",
|
"event_mgk": "MgK-kurs",
|
||||||
@@ -316,6 +369,12 @@
|
|||||||
"event_wind_direction": "Vindriktning",
|
"event_wind_direction": "Vindriktning",
|
||||||
"event_wind_strength": "Vindstyrka",
|
"event_wind_strength": "Vindstyrka",
|
||||||
"event_sea_state": "Havets tillstånd",
|
"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_weather": "Väder",
|
||||||
"event_log": "Log (sm)",
|
"event_log": "Log (sm)",
|
||||||
"event_gps": "GPS-position",
|
"event_gps": "GPS-position",
|
||||||
@@ -323,7 +382,26 @@
|
|||||||
"event_location_placeholder": "z. t.ex. Kiel",
|
"event_location_placeholder": "z. t.ex. Kiel",
|
||||||
"event_remarks": "Anmärkningar / incidenter",
|
"event_remarks": "Anmärkningar / incidenter",
|
||||||
"gps_btn": "Hämta GPS-koordinater",
|
"gps_btn": "Hämta GPS-koordinater",
|
||||||
|
"gps_permission_denied": "Platstillgång nekades. Tillåt det i webbläsar- eller enhetsinställningar och försök igen.",
|
||||||
|
"gps_timeout": "GPS fick tidsgräns. Försök igen utomhus med fri sikt mot himlen.",
|
||||||
|
"gps_position_unavailable": "Ingen GPS-signal tillgänglig. Vänta och försök igen, eller ange koordinater manuellt.",
|
||||||
|
"gps_unavailable": "GPS stöds inte av denna webbläsare eller enhet.",
|
||||||
|
"gps_failed": "GPS-position kunde inte bestämmas.",
|
||||||
|
"gps_fallback_no_location": "GPS misslyckades. Ange en plats under ort/hamn, avresa eller destination, eller skriv koordinater manuellt.",
|
||||||
|
"gps_fallback_success": "Koordinater för \"{{location}}\" hittades via ortsnamn (inte GPS).",
|
||||||
|
"gps_fallback_failed": "GPS och ortnamnssökning misslyckades. Ange koordinater manuellt.",
|
||||||
|
"gps_quality_excellent": "Stark GPS-mottagning (±{{accuracy}} m)",
|
||||||
|
"gps_quality_good": "Bra GPS-mottagning (±{{accuracy}} m)",
|
||||||
|
"gps_quality_fair": "Måttlig GPS-mottagning (±{{accuracy}} m) – gå utomhus för bättre signal.",
|
||||||
|
"gps_quality_poor": "Svag GPS-mottagning (±{{accuracy}} m) – troligen få satelliter. Försök utomhus igen eller kontrollera positionen.",
|
||||||
|
"gps_quality_unknown": "GPS-position övertagen (noggrannhet ej rapporterad av enheten).",
|
||||||
|
"gps_live_intro_title": "Plats för live-logg",
|
||||||
|
"gps_live_intro_body": "Appen behöver din plats för automatiska positionsregistreringar och GPS-knappen.\n\nTryck på „Tillåt plats“ och bekräfta i nästa dialog. Du kan alltid ange position manuellt via „Position“.",
|
||||||
|
"gps_live_intro_allow": "Tillåt plats",
|
||||||
|
"gps_live_intro_later": "Senare",
|
||||||
|
"gps_enable_in_settings_hint": "Platstillgång är blockerad. Du kan tillåta det senare i webbläsar- eller enhetsinställningar (webbplats / app → Plats).",
|
||||||
"weather_btn": "OpenWeatherMap Ring upp väder",
|
"weather_btn": "OpenWeatherMap Ring upp väder",
|
||||||
|
"weather_offline": "OpenWeatherMap kräver internetanslutning. Du är offline.",
|
||||||
"event_wind_pressure": "Lufttryck (hPa)",
|
"event_wind_pressure": "Lufttryck (hPa)",
|
||||||
"event_heel": "Krängning (°)",
|
"event_heel": "Krängning (°)",
|
||||||
"event_sails": "Segelhantering / motor",
|
"event_sails": "Segelhantering / motor",
|
||||||
@@ -337,6 +415,18 @@
|
|||||||
"share_csv": "Aktie",
|
"share_csv": "Aktie",
|
||||||
"export_pdf": "Hämta PDF.",
|
"export_pdf": "Hämta PDF.",
|
||||||
"exporting_pdf": "PDF genereras...",
|
"exporting_pdf": "PDF genereras...",
|
||||||
|
"ai_summary_title": "AI-sammanfattning",
|
||||||
|
"ai_summary_read_only": "Skapad av skepparen — endast läsning för besättningen.",
|
||||||
|
"ai_summary_empty": "Ingen sammanfattning ännu.",
|
||||||
|
"ai_summary_generate": "Generera sammanfattning",
|
||||||
|
"ai_summary_regenerate": "Generera igen",
|
||||||
|
"ai_summary_generating": "Genererar…",
|
||||||
|
"ai_summary_attempts_remaining": "{{remaining}} av {{max}} försök kvar",
|
||||||
|
"ai_summary_error": "AI-sammanfattning misslyckades. Försök igen senare.",
|
||||||
|
"ai_summary_error_no_key": "Ingen OpenRouter API-nyckel konfigurerad på servern.",
|
||||||
|
"ai_summary_error_rate_limited": "Maximalt antal genereringar nått för denna resedag.",
|
||||||
|
"ai_summary_error_forbidden": "Endast skepparen får generera AI-sammanfattningar.",
|
||||||
|
"ai_summary_offline": "AI-sammanfattning kräver internetanslutning. Du är offline.",
|
||||||
"photos_title": "Fotobilagor (E2E-krypterade)",
|
"photos_title": "Fotobilagor (E2E-krypterade)",
|
||||||
"photo_caption_label": "Fotobeskrivning/etikett (valfritt)",
|
"photo_caption_label": "Fotobeskrivning/etikett (valfritt)",
|
||||||
"photo_caption_placeholder": "t.ex. sätta segel nära hamninloppet",
|
"photo_caption_placeholder": "t.ex. sätta segel nära hamninloppet",
|
||||||
@@ -364,12 +454,12 @@
|
|||||||
"track_map_error": "Kartan kunde inte läsas in.",
|
"track_map_error": "Kartan kunde inte läsas in.",
|
||||||
"exporting": "Export...",
|
"exporting": "Export...",
|
||||||
"share_unsupported": "Delning stöds inte på den här enheten. Filen har laddats ner istället.",
|
"share_unsupported": "Delning stöds inte på den här enheten. Filen har laddats ner istället.",
|
||||||
"invite_crew": "Bjud in besättningen",
|
"invite_crew": "Bjud in crewen",
|
||||||
"invite_link_copied": "Länk till inbjudan kopierad till urklipp!",
|
"invite_link_copied": "Länk till inbjudan kopierad till urklipp!",
|
||||||
"invite_link_desc": "Dela den här länken med besättningsmedlemmar för att ge dem skrivrättigheter till loggboken.",
|
"invite_link_desc": "Dela den här länken med Crew-medlemmar för att ge dem skrivrättigheter till loggboken.",
|
||||||
"collaborators_list": "Medlemmar / Besättning",
|
"collaborators_list": "Medlemmar / Crew",
|
||||||
"revoke": "Ta bort",
|
"revoke": "Ta bort",
|
||||||
"revoke_confirm": "Är du säker på att du vill återkalla den här besättningsmedlemmens åtkomst?",
|
"revoke_confirm": "Är du säker på att du vill återkalla den här Crew-medlemmens åtkomst?",
|
||||||
"invite_role": "Roll",
|
"invite_role": "Roll",
|
||||||
"invite_expires": "Länken är giltig i 48 timmar",
|
"invite_expires": "Länken är giltig i 48 timmar",
|
||||||
"nmea_import_title": "Import NMEA log",
|
"nmea_import_title": "Import NMEA log",
|
||||||
@@ -415,8 +505,8 @@
|
|||||||
"nmea_change_engine_stop": "Engine off",
|
"nmea_change_engine_stop": "Engine off",
|
||||||
"nmea_change_autopilot_on": "Autopilot on",
|
"nmea_change_autopilot_on": "Autopilot on",
|
||||||
"nmea_change_autopilot_off": "Autopilot off",
|
"nmea_change_autopilot_off": "Autopilot off",
|
||||||
"nmea_change_gps_lost": "GPS fix lost",
|
"nmea_change_gps_lost": "GPS-position förlorad",
|
||||||
"nmea_change_gps_regained": "GPS fix restored",
|
"nmea_change_gps_regained": "GPS-position återställd",
|
||||||
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
|
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
|
||||||
"nmea_change_departure": "Departure / underway",
|
"nmea_change_departure": "Departure / underway",
|
||||||
"nmea_change_anchor": "Anchored / stop",
|
"nmea_change_anchor": "Anchored / stop",
|
||||||
@@ -430,7 +520,7 @@
|
|||||||
"new_logbook_placeholder": "Loggbokens eller båtens namn",
|
"new_logbook_placeholder": "Loggbokens eller båtens namn",
|
||||||
"logout": "Logga ut",
|
"logout": "Logga ut",
|
||||||
"logged_in_as": "Inloggad som {{name}}",
|
"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.",
|
"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) 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!",
|
"no_logbooks": "Inga loggböcker hittades. Skapa din första loggbok för att komma igång!",
|
||||||
"loading": "Loggböckerna är fulla...",
|
"loading": "Loggböckerna är fulla...",
|
||||||
"status_synced": "Synkroniserad",
|
"status_synced": "Synkroniserad",
|
||||||
@@ -438,20 +528,21 @@
|
|||||||
"delete_btn": "Radera loggbok",
|
"delete_btn": "Radera loggbok",
|
||||||
"section_owned": "Mina loggböcker",
|
"section_owned": "Mina loggböcker",
|
||||||
"section_shared": "Delade loggböcker",
|
"section_shared": "Delade loggböcker",
|
||||||
"section_shared_hint": "Du har blivit inbjuden som besättningsmedlem. Skepparens profil och inställningar tillhör ägaren.",
|
"section_shared_hint": "Du har blivit inbjuden som Crew-medlem. Skepparens profil och inställningar tillhör ägaren.",
|
||||||
"role_owner": "Egen loggbok",
|
"role_owner": "Egen loggbok",
|
||||||
"role_owner_hint": "Du är ägare och skeppare till denna loggbok",
|
"role_owner_hint": "Du är ägare och skeppare till denna loggbok",
|
||||||
"role_crew": "Tillträde för besättningen",
|
"role_crew": "Tillträde för crewen",
|
||||||
"role_crew_hint": "Inbjuden loggbok - du kan arbeta som besättning och underteckna den",
|
"role_crew_hint": "Inbjuden loggbok - du kan arbeta som crew och underteckna den",
|
||||||
"role_read": "Endast läsning",
|
"role_read": "Endast läsning",
|
||||||
"role_read_hint": "Delad loggbok - endast visning, ingen redigering",
|
"role_read_hint": "Delad loggbok - endast visning, ingen redigering",
|
||||||
"open_profile": "Öppna profil för {{name}}",
|
"open_profile": "Öppna profil för {{name}}",
|
||||||
|
"open_logbook": "Öppna loggbok ”{{title}}”",
|
||||||
"edit_title": "Byt namn på loggbok",
|
"edit_title": "Byt namn på loggbok",
|
||||||
"edit_placeholder": "Nytt namn på loggboken",
|
"edit_placeholder": "Nytt namn på loggboken",
|
||||||
"edit_success": "Loggboken har framgångsrikt bytt namn",
|
"edit_success": "Loggboken har framgångsrikt bytt namn",
|
||||||
"edit_btn": "Byt namn på",
|
"edit_btn": "Byt namn på",
|
||||||
"filter_label": "Filtrera loggböcker",
|
"filter_label": "Filtrera loggböcker",
|
||||||
"filter_placeholder": "Namn, årtal eller datum ...",
|
"filter_placeholder": "Namn, årtal, datum, crew eller fartyg …",
|
||||||
"filter_clear": "Återställ filter",
|
"filter_clear": "Återställ filter",
|
||||||
"filter_results": "{{count}} Träffar",
|
"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.",
|
"filter_no_results": "Inga loggböcker matchar din sökning. Försök med ett annat namn eller ett annat år.",
|
||||||
@@ -576,23 +667,88 @@
|
|||||||
"tour_desc": "Låt dig vägledas genom de viktigaste områdena i appen igen.",
|
"tour_desc": "Låt dig vägledas genom de viktigaste områdena i appen igen.",
|
||||||
"tour_restart": "Starta resan igen",
|
"tour_restart": "Starta resan igen",
|
||||||
"push_title": "Push-meddelanden",
|
"push_title": "Push-meddelanden",
|
||||||
"push_desc": "Som loggboksägare får du ett meddelande när inbjudna besättningsmedlemmar synkroniserar ändringar. Inget innehåll överförs i klartext.",
|
"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 besättningen",
|
"push_enable": "Meddela oss om förändringar i crewen",
|
||||||
"push_active": "Push-meddelanden är aktiva på den här enheten.",
|
"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_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_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_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."
|
"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": {
|
"crew": {
|
||||||
"title": "Profiler för skeppare och besättning",
|
"title": "Profiler för skeppare och crew",
|
||||||
"skipper_section": "Skepparens profil",
|
"skipper_section": "Skepparens profil",
|
||||||
"skipper_read_only_hint": "Skepparens profil kan endast redigeras av loggbokens ägare.",
|
"skipper_read_only_hint": "Skepparens profil kan endast redigeras av loggbokens ägare.",
|
||||||
"crew_section": "Besättningslista",
|
"crew_section": "Crew-lista",
|
||||||
"add_crew": "Lägg till besättningsmedlem",
|
"add_crew": "Lägg till Crew-medlem",
|
||||||
"edit_crew": "Redigera besättningsmedlem",
|
"edit_crew": "Redigera Crew-medlem",
|
||||||
"no_crew": "Inga besättningsmedlemmar har lagts till ännu.",
|
"no_crew": "Inga Crew-medlemmar har lagts till ännu.",
|
||||||
"max_crew": "Maximalt antal på 5 besättningsmedlemmar uppnås.",
|
"max_crew": "Maximalt antal på 12 Crew-medlemmar i poolen uppnått.",
|
||||||
"name": "Namn",
|
"name": "Namn",
|
||||||
"address": "adress",
|
"address": "adress",
|
||||||
"birthdate": "Födelsedag",
|
"birthdate": "Födelsedag",
|
||||||
@@ -605,8 +761,8 @@
|
|||||||
"save": "Spara skeppardata",
|
"save": "Spara skeppardata",
|
||||||
"save_member": "Spara medlem",
|
"save_member": "Spara medlem",
|
||||||
"saved": "Skepparens profil har sparats!",
|
"saved": "Skepparens profil har sparats!",
|
||||||
"loading": "Besättningsfilerna är laddade...",
|
"loading": "Crew-filerna är laddade...",
|
||||||
"delete_confirm": "Är du säker på att du vill ta bort den här besättningsmedlemmen?"
|
"delete_confirm": "Är du säker på att du vill ta bort den här Crew-medlemmen?"
|
||||||
},
|
},
|
||||||
"deviation": {
|
"deviation": {
|
||||||
"title": "Tabell för kompassavvikelse",
|
"title": "Tabell för kompassavvikelse",
|
||||||
@@ -628,7 +784,7 @@
|
|||||||
"weather_date_mismatch": "Väderdata kan endast hämtas för idag ({{today}}). Denna loggbokspost är daterad {{date}}.",
|
"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.",
|
"gps_error": "Ange en plats eller bestäm GPS-koordinaterna.",
|
||||||
"share_title": "Aktieloggbok (skrivskyddad)",
|
"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 besättning. Krypteringsnycklarna överförs aldrig till servern (de finns kvar i hashdelen av URL:en).",
|
"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_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_enable": "Aktivera offentlig länk",
|
||||||
"share_copied": "Länk kopierad!",
|
"share_copied": "Länk kopierad!",
|
||||||
@@ -636,25 +792,25 @@
|
|||||||
"link_qr_hint": "Skanna QR-koden med mobilen",
|
"link_qr_hint": "Skanna QR-koden med mobilen",
|
||||||
"link_qr_alt": "QR-kod för länken",
|
"link_qr_alt": "QR-kod för länken",
|
||||||
"danger_zone_title": "Farlig zon",
|
"danger_zone_title": "Farlig zon",
|
||||||
"danger_zone_desc": "Om du raderar ditt konto raderas oåterkalleligen alla dina Passkey, loggböcker, fartygsdata, besättningsprofiler, reseanteckningar och E2E-nycklar. Denna åtgärd kan inte ångras.",
|
"danger_zone_desc": "Om du raderar ditt konto raderas oåterkalleligen alla dina Passkey, loggböcker, fartygsdata, Crew-profiler, reseanteckningar och E2E-nycklar. Denna åtgärd kan inte ångras.",
|
||||||
"delete_account_btn": "Ta bort konto oåterkalleligt",
|
"delete_account_btn": "Ta bort konto oåterkalleligt",
|
||||||
"delete_account_confirm_title": "Radera konto?",
|
"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_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_yes": "Ja, radera konto och all data",
|
||||||
"delete_account_confirm_no": "Avbryt",
|
"delete_account_confirm_no": "Avbryt",
|
||||||
"delete_account_failed": "Kontot kunde inte raderas. Vänligen försök igen.",
|
"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.",
|
"delete_backup_hint": "Tips: Skapa säkerhetskopior av dina loggböcker (.daagbok) i inställningarna för varje loggbok innan du raderar dem.",
|
||||||
"deleting_account": "Kontot kommer att raderas...",
|
"deleting_account": "Kontot kommer att raderas...",
|
||||||
"invite_push_prompt_title": "Aktivera push-meddelanden?",
|
"invite_push_prompt_title": "Aktivera push-meddelanden?",
|
||||||
"invite_push_prompt_message": "Så snart inbjudna besättningsmedlemmar synkroniserar ändringar kan du bli informerad via push. Inget loggboksinnehåll skickas i klartext.",
|
"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 besättningsmedlemmar 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_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_enable": "Aktivera nu",
|
||||||
"invite_push_prompt_later": "Senare",
|
"invite_push_prompt_later": "Senare",
|
||||||
"invite_push_prompt_success": "Push-meddelanden är aktiva på den här enheten.",
|
"invite_push_prompt_success": "Push-meddelanden är aktiva på den här enheten.",
|
||||||
"backup_title": "Säkerhetskopiering och återställning",
|
"backup_title": "Säkerhetskopiering och återställning",
|
||||||
"backup_desc": "Komplett krypterad säkerhetskopia av denna loggbok (poster, foton, GPS-spår, besättning, fartyg). Skyddad med lösenfras för säkerhetskopian - för återställning till detta eller ett nytt konto.",
|
"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_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_export_desc": "Laddar ner alla lokala data som ett komprimerat .daagbok-arkiv. Förvara filen och lösenfrasen separat och säkert.",
|
||||||
"backup_restore_title": "Återställ säkerhetskopian",
|
"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_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": "Lösenord för säkerhetskopiering",
|
||||||
@@ -666,7 +822,13 @@
|
|||||||
"backup_export_btn": "Ladda ner backup",
|
"backup_export_btn": "Ladda ner backup",
|
||||||
"backup_exporting": "Säkerhetskopian skapas...",
|
"backup_exporting": "Säkerhetskopian skapas...",
|
||||||
"backup_export_success": "Säkerhetskopia skapad ({{count}} resdagar).",
|
"backup_export_success": "Säkerhetskopia skapad ({{count}} resdagar).",
|
||||||
"backup_file_label": "Säkerhetskopieringsfil (.daagbok.json)",
|
"backup_file_label": "Säkerhetskopieringsfil (.daagbok)",
|
||||||
|
"backup_export_progress": "Packar filer {{current}} / {{total}}…",
|
||||||
|
"backup_invalid_archive": "Filen är inte ett giltigt backup-arkiv.",
|
||||||
|
"backup_version_unsupported": "Gammalt backup-format (v1). Använd en aktuell .daagbok-säkerhetskopia.",
|
||||||
|
"backup_import_size_confirm": "Denna säkerhetskopia är ca. {{size}} okomprimerad. Återställning kan ta längre tid. Fortsätta?",
|
||||||
|
"backup_stat_voice": "{{count}} röstanteckningar",
|
||||||
|
"backup_stat_size": "Ca. {{size}} okomprimerat",
|
||||||
"backup_preview_btn": "Kontrollera innehåll",
|
"backup_preview_btn": "Kontrollera innehåll",
|
||||||
"backup_previewing": "Check...",
|
"backup_previewing": "Check...",
|
||||||
"backup_restore_btn": "Återställ",
|
"backup_restore_btn": "Återställ",
|
||||||
@@ -682,7 +844,7 @@
|
|||||||
"backup_new_id_confirm": "Importera säkerhetskopian som en ny loggbok med ett nytt ID?",
|
"backup_new_id_confirm": "Importera säkerhetskopian som en ny loggbok med ett nytt ID?",
|
||||||
"backup_stat_entries": "{{count}} Resdagar",
|
"backup_stat_entries": "{{count}} Resdagar",
|
||||||
"backup_stat_photos": "{{count}} Foton",
|
"backup_stat_photos": "{{count}} Foton",
|
||||||
"backup_stat_crew": "{{count}} Besättningens uppgifter",
|
"backup_stat_crew": "{{count}} Crew-poster",
|
||||||
"backup_stat_tracks": "{{count}} GPS-spår",
|
"backup_stat_tracks": "{{count}} GPS-spår",
|
||||||
"backup_exported_at": "Exporterad: {{date}}"
|
"backup_exported_at": "Exporterad: {{date}}"
|
||||||
},
|
},
|
||||||
@@ -760,7 +922,7 @@
|
|||||||
"join_again": "Gå med igen",
|
"join_again": "Gå med igen",
|
||||||
"login_or_register_hint": "Logga in eller registrera ett konto för att gå med i loggboken.",
|
"login_or_register_hint": "Logga in eller registrera ett konto för att gå med i loggboken.",
|
||||||
"or_sign_up": "ELLER REGISTRERA DIG IGEN",
|
"or_sign_up": "ELLER REGISTRERA DIG IGEN",
|
||||||
"register_crew_account": "Skapa ett nytt konto för besättningen",
|
"register_crew_account": "Skapa ett nytt konto för crewen",
|
||||||
"username_label": "Användarens namn",
|
"username_label": "Användarens namn",
|
||||||
"create_passkey": "Skapa Passkey.",
|
"create_passkey": "Skapa Passkey.",
|
||||||
"switch_language_en": "Engelska",
|
"switch_language_en": "Engelska",
|
||||||
@@ -824,7 +986,7 @@
|
|||||||
},
|
},
|
||||||
"welcome_public": {
|
"welcome_public": {
|
||||||
"title": "Välkommen ombord!",
|
"title": "Välkommen ombord!",
|
||||||
"body": "Utforska vår demologgbok med tre dagars resor i Kielfjorden - utan konto. Den här korta rundturen visar dig fartygsdata, besättning och loggboksanteckningar."
|
"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": {
|
"nav_logs": {
|
||||||
"title": "Loggboksanteckningar",
|
"title": "Loggboksanteckningar",
|
||||||
@@ -843,12 +1005,20 @@
|
|||||||
"body": "Ladda upp GPX-filer eller visa redan sparade rutter på kartan - inklusive avstånd och hastighet."
|
"body": "Ladda upp GPX-filer eller visa redan sparade rutter på kartan - inklusive avstånd och hastighet."
|
||||||
},
|
},
|
||||||
"nav_vessel": {
|
"nav_vessel": {
|
||||||
"title": "Fartygsdata",
|
"title": "Fartyg för loggbok",
|
||||||
"body": "Ange namn, dimensioner och tekniska data för din yacht - fyll i en gång, tillgänglig för alla resdagar."
|
"body": "Välj fartyg från flottan för denna loggbok. Hantera fartyg i användarprofilen under Flotta och besättning."
|
||||||
},
|
},
|
||||||
"nav_crew": {
|
"profile_vessel_pool": {
|
||||||
"title": "Besättningslista",
|
"title": "Fartygsflotta",
|
||||||
"body": "Hantera besättningsmedlemmar och tilldela dem resdagar senare."
|
"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": {
|
"nav_stats": {
|
||||||
"title": "Kontrollpanel för statistik",
|
"title": "Kontrollpanel för statistik",
|
||||||
@@ -874,7 +1044,7 @@
|
|||||||
},
|
},
|
||||||
"seo": {
|
"seo": {
|
||||||
"title": "Kapteins Daagbok - Gratis digital loggbok för båtar (reklamfri)",
|
"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, besättnings- och fartygsdata på ett säkert sätt - även offline som PWA.",
|
"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",
|
"keywords": "Yachtloggbok, skeppsdagbok, ombordloggbok, segling, Passkey, E2E kryptering, GPS-spår, sjöfartsloggbok, gratis, reklamfri, gratis, utan reklam",
|
||||||
"ogImageAlt": "Kapteins Daagbok Logotyp"
|
"ogImageAlt": "Kapteins Daagbok Logotyp"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,3 +18,62 @@ body {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import './App.css'
|
|||||||
import './i18n'
|
import './i18n'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
import { applyAppearanceToDocument } from './services/appearance.ts'
|
import { applyAppearanceToDocument } from './services/appearance.ts'
|
||||||
|
import { flushPendingPwaBootEvents } from './services/analytics.ts'
|
||||||
import {
|
import {
|
||||||
installStaleAssetRecovery,
|
installStaleAssetRecovery,
|
||||||
markReloadAttempt,
|
markReloadAttempt,
|
||||||
@@ -14,6 +15,15 @@ import {
|
|||||||
} from './services/pwaStartup.ts'
|
} from './services/pwaStartup.ts'
|
||||||
import { redirectToPasskeyCompatibleHostIfNeeded } from './utils/passkeyHost.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. */
|
/** Stale PWA precache on localhost can shadow Vite dev modules. */
|
||||||
async function clearDevServiceWorkerCaches(): Promise<void> {
|
async function clearDevServiceWorkerCaches(): Promise<void> {
|
||||||
if (!import.meta.env.DEV || !('serviceWorker' in navigator)) return
|
if (!import.meta.env.DEV || !('serviceWorker' in navigator)) return
|
||||||
@@ -47,6 +57,10 @@ async function bootstrap(): Promise<void> {
|
|||||||
|
|
||||||
applyAppearanceToDocument()
|
applyAppearanceToDocument()
|
||||||
installStaleAssetRecovery()
|
installStaleAssetRecovery()
|
||||||
|
flushPendingPwaBootEvents()
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
flushPendingPwaBootEvents()
|
||||||
|
}, { once: true })
|
||||||
await clearDevServiceWorkerCaches()
|
await clearDevServiceWorkerCaches()
|
||||||
|
|
||||||
const startupResult = await reconcileVersionOnStartup()
|
const startupResult = await reconcileVersionOnStartup()
|
||||||
@@ -59,6 +73,17 @@ async function bootstrap(): Promise<void> {
|
|||||||
return
|
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')
|
const rootEl = document.getElementById('root')
|
||||||
if (!rootEl) {
|
if (!rootEl) {
|
||||||
throw new Error('Missing #root element')
|
throw new Error('Missing #root element')
|
||||||
@@ -69,6 +94,7 @@ async function bootstrap(): Promise<void> {
|
|||||||
<App />
|
<App />
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
window.__KDB_APP_BOOTSTRAPPED = true
|
||||||
}
|
}
|
||||||
|
|
||||||
void bootstrap().catch((err) => {
|
void bootstrap().catch((err) => {
|
||||||
@@ -76,4 +102,5 @@ void bootstrap().catch((err) => {
|
|||||||
renderBootstrapError(
|
renderBootstrapError(
|
||||||
'Die App konnte nicht gestartet werden. Bitte neu laden oder die App vollständig beenden und erneut öffnen.',
|
'Die App konnte nicht gestartet werden. Bitte neu laden oder die App vollständig beenden und erneut öffnen.',
|
||||||
)
|
)
|
||||||
|
window.__KDB_APP_BOOTSTRAPPED = false
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { buildTravelDayContext } from './aiSummary.js'
|
||||||
|
import type { LogEventPayload } from '../utils/logEntryPayload.js'
|
||||||
|
|
||||||
|
const t = ((key: string, opts?: Record<string, unknown>) => {
|
||||||
|
if (key === 'logs.live_motor_start') return 'Motor started'
|
||||||
|
if (key === 'logs.live_event_generic') return 'Event'
|
||||||
|
if (opts && 'course' in opts) return `Course ${opts.course}`
|
||||||
|
return key
|
||||||
|
}) as any
|
||||||
|
|
||||||
|
describe('buildTravelDayContext', () => {
|
||||||
|
it('includes route metadata and formatted events', () => {
|
||||||
|
const events: LogEventPayload[] = [
|
||||||
|
{
|
||||||
|
time: '09:00',
|
||||||
|
mgk: '180',
|
||||||
|
rwk: '',
|
||||||
|
windPressure: '',
|
||||||
|
windDirection: '',
|
||||||
|
windStrength: '',
|
||||||
|
seaState: '',
|
||||||
|
visibility: '',
|
||||||
|
weatherIcon: '',
|
||||||
|
current: '',
|
||||||
|
heel: '',
|
||||||
|
sailsOrMotor: 'Genua',
|
||||||
|
logReading: '',
|
||||||
|
distance: '',
|
||||||
|
gpsLat: '',
|
||||||
|
gpsLng: '',
|
||||||
|
remarks: '__live:motor_start'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const context = buildTravelDayContext(
|
||||||
|
{
|
||||||
|
date: '2026-06-03',
|
||||||
|
dayOfTravel: '5',
|
||||||
|
departure: 'Kiel',
|
||||||
|
destination: 'Copenhagen',
|
||||||
|
freshwater: { morning: 100, refilled: 0, evening: 80, consumption: 20 },
|
||||||
|
fuel: { morning: 50, refilled: 10, evening: 40, consumption: 20 },
|
||||||
|
greywaterLevel: 0,
|
||||||
|
trackDistanceNm: 42.5,
|
||||||
|
motorHours: 3.5,
|
||||||
|
events
|
||||||
|
},
|
||||||
|
t
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(context.departure).toBe('Kiel')
|
||||||
|
expect(context.destination).toBe('Copenhagen')
|
||||||
|
expect(context.trackDistanceNm).toBe(42.5)
|
||||||
|
expect(context.motorHours).toBe(3.5)
|
||||||
|
expect(context.events).toHaveLength(1)
|
||||||
|
expect(context.events[0].summary).toBe('Motor started')
|
||||||
|
expect(context.events[0].sailsOrMotor).toBe('Genua')
|
||||||
|
expect(context.greywater).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import type { TFunction } from 'i18next'
|
||||||
|
import { apiFetch } from './api.js'
|
||||||
|
import { formatEventSummary } from '../utils/formatEventSummary.js'
|
||||||
|
import { sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayload.js'
|
||||||
|
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
|
||||||
|
|
||||||
|
export class TravelDaySummaryApiError extends Error {
|
||||||
|
code: 'NO_KEY' | 'FORBIDDEN' | 'RATE_LIMITED' | 'OFFLINE' | 'REQUEST_FAILED'
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
code: 'NO_KEY' | 'FORBIDDEN' | 'RATE_LIMITED' | 'OFFLINE' | 'REQUEST_FAILED' = 'REQUEST_FAILED'
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'TravelDaySummaryApiError'
|
||||||
|
this.code = code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TravelDaySummaryContext {
|
||||||
|
date: string
|
||||||
|
dayOfTravel: string
|
||||||
|
departure: string
|
||||||
|
destination: string
|
||||||
|
trackDistanceNm?: number
|
||||||
|
trackSpeedMaxKn?: number
|
||||||
|
trackSpeedAvgKn?: number
|
||||||
|
motorHours?: number
|
||||||
|
freshwater?: {
|
||||||
|
morning: number
|
||||||
|
refilled: number
|
||||||
|
evening: number
|
||||||
|
consumption: number
|
||||||
|
}
|
||||||
|
fuel?: {
|
||||||
|
morning: number
|
||||||
|
refilled: number
|
||||||
|
evening: number
|
||||||
|
consumption: number
|
||||||
|
}
|
||||||
|
greywater?: { level: number }
|
||||||
|
events: Array<{
|
||||||
|
time: string
|
||||||
|
summary: string
|
||||||
|
sailsOrMotor?: string
|
||||||
|
mgk?: string
|
||||||
|
windDirection?: string
|
||||||
|
windStrength?: string
|
||||||
|
windPressure?: string
|
||||||
|
seaState?: string
|
||||||
|
visibility?: string
|
||||||
|
distance?: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TravelDaySummaryInput {
|
||||||
|
date: string
|
||||||
|
dayOfTravel: string
|
||||||
|
departure: string
|
||||||
|
destination: string
|
||||||
|
trackDistanceNm?: number
|
||||||
|
trackSpeedMaxKn?: number
|
||||||
|
trackSpeedAvgKn?: number
|
||||||
|
motorHours?: number
|
||||||
|
freshwater: { morning: number; refilled: number; evening: number; consumption: number }
|
||||||
|
fuel: { morning: number; refilled: number; evening: number; consumption: number }
|
||||||
|
greywaterLevel?: number
|
||||||
|
events: LogEventPayload[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUMMARY_FETCH_TIMEOUT_MS = 90_000
|
||||||
|
|
||||||
|
export function buildTravelDayContext(
|
||||||
|
input: TravelDaySummaryInput,
|
||||||
|
t: TFunction
|
||||||
|
): TravelDaySummaryContext {
|
||||||
|
const context: TravelDaySummaryContext = {
|
||||||
|
date: input.date,
|
||||||
|
dayOfTravel: input.dayOfTravel,
|
||||||
|
departure: input.departure,
|
||||||
|
destination: input.destination,
|
||||||
|
freshwater: input.freshwater,
|
||||||
|
fuel: input.fuel,
|
||||||
|
events: sortLogEventsByTime(input.events).map((event) => ({
|
||||||
|
time: event.time,
|
||||||
|
summary: formatEventSummary(event, t),
|
||||||
|
...(event.sailsOrMotor ? { sailsOrMotor: event.sailsOrMotor } : {}),
|
||||||
|
...(event.mgk ? { mgk: event.mgk } : {}),
|
||||||
|
...(event.windDirection ? { windDirection: event.windDirection } : {}),
|
||||||
|
...(event.windStrength ? { windStrength: event.windStrength } : {}),
|
||||||
|
...(event.windPressure ? { windPressure: event.windPressure } : {}),
|
||||||
|
...(event.seaState ? { seaState: event.seaState } : {}),
|
||||||
|
...(event.visibility ? { visibility: event.visibility } : {}),
|
||||||
|
...(event.distance ? { distance: event.distance } : {})
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.trackDistanceNm !== undefined) context.trackDistanceNm = input.trackDistanceNm
|
||||||
|
if (input.trackSpeedMaxKn !== undefined) context.trackSpeedMaxKn = input.trackSpeedMaxKn
|
||||||
|
if (input.trackSpeedAvgKn !== undefined) context.trackSpeedAvgKn = input.trackSpeedAvgKn
|
||||||
|
if (input.motorHours !== undefined && input.motorHours > 0) context.motorHours = input.motorHours
|
||||||
|
if (input.greywaterLevel !== undefined && input.greywaterLevel > 0) {
|
||||||
|
context.greywater = { level: input.greywaterLevel }
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapApiError(status: number, data: unknown): TravelDaySummaryApiError {
|
||||||
|
const code =
|
||||||
|
typeof data === 'object' && data !== null && 'code' in data
|
||||||
|
? String((data as { code?: string }).code)
|
||||||
|
: ''
|
||||||
|
|
||||||
|
if (status === 503 || code === 'NO_KEY') {
|
||||||
|
return new TravelDaySummaryApiError('No OpenRouter API key configured', 'NO_KEY')
|
||||||
|
}
|
||||||
|
if (status === 403) {
|
||||||
|
return new TravelDaySummaryApiError('Forbidden', 'FORBIDDEN')
|
||||||
|
}
|
||||||
|
if (status === 429 || code === 'RATE_LIMITED') {
|
||||||
|
return new TravelDaySummaryApiError('Rate limit exceeded', 'RATE_LIMITED')
|
||||||
|
}
|
||||||
|
|
||||||
|
const message =
|
||||||
|
typeof data === 'object' && data !== null && 'error' in data && typeof (data as { error: unknown }).error === 'string'
|
||||||
|
? (data as { error: string }).error
|
||||||
|
: 'Request failed'
|
||||||
|
return new TravelDaySummaryApiError(message, 'REQUEST_FAILED')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTravelDaySummaryUsage(
|
||||||
|
logbookId: string,
|
||||||
|
entryId: string
|
||||||
|
): Promise<{ remainingAttempts: number; maxAttempts: number }> {
|
||||||
|
const params = new URLSearchParams({ logbookId, entryId })
|
||||||
|
const res = await apiFetch(`/api/ai/usage?${params.toString()}`)
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
if (!res.ok) throw mapApiError(res.status, data)
|
||||||
|
return data as { remainingAttempts: number; maxAttempts: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateTravelDaySummary(params: {
|
||||||
|
logbookId: string
|
||||||
|
entryId: string
|
||||||
|
language: string
|
||||||
|
context: TravelDaySummaryContext
|
||||||
|
}): Promise<{ summary: string; remainingAttempts: number; maxAttempts: number }> {
|
||||||
|
if (!navigator.onLine) {
|
||||||
|
throw new TravelDaySummaryApiError('Offline', 'OFFLINE')
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = window.setTimeout(() => controller.abort(), SUMMARY_FETCH_TIMEOUT_MS)
|
||||||
|
|
||||||
|
let res: Response
|
||||||
|
try {
|
||||||
|
res = await apiFetch('/api/ai/summary', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(params),
|
||||||
|
signal: controller.signal
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||||
|
throw new TravelDaySummaryApiError('AI summary request timed out')
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
if (!res.ok) throw mapApiError(res.status, data)
|
||||||
|
|
||||||
|
trackPlausibleEvent(PlausibleEvents.AI_SUMMARY_GENERATED)
|
||||||
|
|
||||||
|
return data as { summary: string; remainingAttempts: number; maxAttempts: number }
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ export const PlausibleEvents = {
|
|||||||
PUSH_ENABLED: 'Push Enabled',
|
PUSH_ENABLED: 'Push Enabled',
|
||||||
PUSH_DISABLED: 'Push Disabled',
|
PUSH_DISABLED: 'Push Disabled',
|
||||||
FOOTER_LINK_CLICKED: 'Footer Link Clicked',
|
FOOTER_LINK_CLICKED: 'Footer Link Clicked',
|
||||||
|
KOFI_LINK_CLICKED: 'Ko-fi Link Clicked',
|
||||||
PROFILE_OPENED: 'Profile Opened',
|
PROFILE_OPENED: 'Profile Opened',
|
||||||
PASSKEY_ADDED: 'Passkey Added',
|
PASSKEY_ADDED: 'Passkey Added',
|
||||||
PASSKEY_REMOVED: 'Passkey Removed',
|
PASSKEY_REMOVED: 'Passkey Removed',
|
||||||
@@ -39,12 +40,29 @@ export const PlausibleEvents = {
|
|||||||
NMEA_IMPORTED: 'NMEA Imported',
|
NMEA_IMPORTED: 'NMEA Imported',
|
||||||
NMEA_UPLOADED: 'NMEA Uploaded',
|
NMEA_UPLOADED: 'NMEA Uploaded',
|
||||||
LIVE_LOG_OPENED: 'Live Log Opened',
|
LIVE_LOG_OPENED: 'Live Log Opened',
|
||||||
LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged'
|
LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged',
|
||||||
|
VOICE_MEMO_UPLOADED: 'Voice Memo Uploaded',
|
||||||
|
OWM_WEATHER_FETCHED: 'OWM Weather Fetched',
|
||||||
|
AI_SUMMARY_GENERATED: 'AI Summary Generated',
|
||||||
|
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
|
} as const
|
||||||
|
|
||||||
|
/** Where a successful OpenWeatherMap API call originated (no coordinates or place names). */
|
||||||
|
export type OwmAnalyticsSource = 'live_log' | 'entry_editor' | 'entry_editor_gps_lookup'
|
||||||
|
|
||||||
export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents]
|
export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents]
|
||||||
|
|
||||||
export type PlausibleEventProps = Record<string, string | number | boolean>
|
export type PlausibleEventProps = Record<string, string | number | boolean>
|
||||||
|
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 {
|
export function trackPlausibleEvent(name: PlausibleEventName, props?: PlausibleEventProps): void {
|
||||||
if (typeof window.plausible !== 'function') return
|
if (typeof window.plausible !== 'function') return
|
||||||
@@ -54,3 +72,52 @@ export function trackPlausibleEvent(name: PlausibleEventName, props?: PlausibleE
|
|||||||
}
|
}
|
||||||
window.plausible(name)
|
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(
|
export async function apiFetch(
|
||||||
input: string,
|
input: string,
|
||||||
init: RequestInit = {}
|
init: RequestInit = {},
|
||||||
|
timeoutMs = 15000
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const headers = new Headers(init.headers)
|
const headers = new Headers(init.headers)
|
||||||
if (init.body !== undefined && !headers.has('Content-Type')) {
|
if (init.body !== undefined && !headers.has('Content-Type')) {
|
||||||
headers.set('Content-Type', 'application/json')
|
headers.set('Content-Type', 'application/json')
|
||||||
}
|
}
|
||||||
|
|
||||||
return fetch(input, {
|
const controller = new AbortController()
|
||||||
...init,
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
|
||||||
headers,
|
|
||||||
credentials: 'include'
|
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> {
|
export async function apiJson<T>(
|
||||||
const res = await apiFetch(input, init)
|
input: string,
|
||||||
|
init: RequestInit = {},
|
||||||
|
timeoutMs = 15000
|
||||||
|
): Promise<T> {
|
||||||
|
const res = await apiFetch(input, init, timeoutMs)
|
||||||
const data = await res.json().catch(() => ({}))
|
const data = await res.json().catch(() => ({}))
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const message =
|
const message =
|
||||||
|
|||||||
@@ -556,9 +556,15 @@ export async function deleteAccount(): Promise<boolean> {
|
|||||||
db.deviations.clear(),
|
db.deviations.clear(),
|
||||||
db.entries.clear(),
|
db.entries.clear(),
|
||||||
db.photos.clear(),
|
db.photos.clear(),
|
||||||
|
db.voiceMemos.clear(),
|
||||||
db.gpsTracks.clear(),
|
db.gpsTracks.clear(),
|
||||||
db.syncQueue.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
|
// 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.')
|
throw new Error('Encryption key not found. User must log in.')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Fetch Yacht details
|
const { resolveVesselForLogbook } = await import('./resolveVessel.js')
|
||||||
const yachtRecord = await db.yachts.get(logbookId);
|
const yacht = await resolveVesselForLogbook(logbookId)
|
||||||
if (yachtRecord) {
|
if (yacht) {
|
||||||
try {
|
yachtName = yacht.name || ''
|
||||||
const yacht = await decryptJson(yachtRecord.encryptedData, yachtRecord.iv, yachtRecord.tag, masterKey);
|
homePort = yacht.homePort || ''
|
||||||
yachtName = yacht.name || '';
|
owner = yacht.owner || ''
|
||||||
homePort = yacht.port || '';
|
charter = yacht.charterCompany || ''
|
||||||
owner = yacht.owner || '';
|
registration = yacht.registrationNumber || ''
|
||||||
charter = yacht.charter || '';
|
callsign = yacht.callSign || ''
|
||||||
registration = yacht.registration || '';
|
atis = yacht.atis || ''
|
||||||
callsign = yacht.callsign || '';
|
mmsi = yacht.mmsi || ''
|
||||||
atis = yacht.atis || '';
|
|
||||||
mmsi = yacht.mmsi || '';
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to decrypt yacht details for CSV:', e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Fetch logbook entries
|
// 2. Fetch logbook entries
|
||||||
@@ -79,11 +74,11 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
|||||||
|
|
||||||
// Headers matching the requested event fields & metadata
|
// Headers matching the requested event fields & metadata
|
||||||
const headers = [
|
const headers = [
|
||||||
'Date', 'Day of Travel', 'Departure Port', 'Destination Port',
|
'Date', 'Day of Travel', 'Departure Port', 'Destination Port', 'AI Summary',
|
||||||
'Skipper Signature', 'Crew Signature',
|
'Skipper Signature', 'Crew Signature',
|
||||||
'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)', 'Motor Hours (h)',
|
'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)', 'Motor Hours (h)',
|
||||||
'Event Time', 'MgK Course', 'RwK Course',
|
'Event Time', 'Event Creator', '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)',
|
'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)',
|
||||||
'Latitude', 'Longitude', 'Remarks',
|
'Latitude', 'Longitude', 'Remarks',
|
||||||
'Freshwater Morning (L)', 'Freshwater Refilled (L)', 'Freshwater Evening (L)', 'Freshwater Consumption (L)',
|
'Freshwater Morning (L)', 'Freshwater Refilled (L)', 'Freshwater Evening (L)', 'Freshwater Consumption (L)',
|
||||||
@@ -125,17 +120,19 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
|||||||
const fuelE = entry.fuel?.evening ?? '';
|
const fuelE = entry.fuel?.evening ?? '';
|
||||||
const fuelCons = entry.fuel?.consumption ?? '';
|
const fuelCons = entry.fuel?.consumption ?? '';
|
||||||
const greywaterLevel = entry.greywater?.level ?? '';
|
const greywaterLevel = entry.greywater?.level ?? '';
|
||||||
|
const aiSummary = entry.aiSummary ?? '';
|
||||||
|
|
||||||
|
const crewSnapshots = (entry.crewSnapshotsById as Record<string, any>) || {};
|
||||||
const eventsList = entry.events || [];
|
const eventsList = entry.events || [];
|
||||||
if (eventsList.length === 0) {
|
if (eventsList.length === 0) {
|
||||||
// Create one row even if there are no events for the day
|
// Create one row even if there are no events for the day
|
||||||
rows.push([
|
rows.push([
|
||||||
dateVal, travelDay, dep, dest,
|
dateVal, travelDay, dep, dest, aiSummary,
|
||||||
signS, signC,
|
signS, signC,
|
||||||
trackDist, trackMax, trackAvg, motorH,
|
trackDist, trackMax, trackAvg, motorH,
|
||||||
'', '', '',
|
|
||||||
'', '', '', '',
|
'', '', '', '',
|
||||||
'', '', '', '', '',
|
'', '', '', '', '',
|
||||||
|
'', '', '', '', '',
|
||||||
'', '', '',
|
'', '', '',
|
||||||
fwM, fwR, fwE, fwCons,
|
fwM, fwR, fwE, fwCons,
|
||||||
fuelM, fuelR, fuelE, fuelCons,
|
fuelM, fuelR, fuelE, fuelCons,
|
||||||
@@ -146,12 +143,23 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
|||||||
// Sort events chronologically by time
|
// Sort events chronologically by time
|
||||||
const sortedEvents = sortLogEventsByTime(eventsList);
|
const sortedEvents = sortLogEventsByTime(eventsList);
|
||||||
for (const ev of sortedEvents) {
|
for (const ev of sortedEvents) {
|
||||||
|
const creatorSnap = ev.creatorId ? crewSnapshots[ev.creatorId] : null;
|
||||||
|
let creatorName = '';
|
||||||
|
if (creatorSnap) {
|
||||||
|
creatorName = creatorSnap.name || '';
|
||||||
|
} else if (ev.creatorId === 'skipper') {
|
||||||
|
creatorName = 'Skipper';
|
||||||
|
} else if (ev.creatorId) {
|
||||||
|
creatorName = ev.creatorId;
|
||||||
|
}
|
||||||
|
|
||||||
rows.push([
|
rows.push([
|
||||||
dateVal, travelDay, dep, dest,
|
dateVal, travelDay, dep, dest, aiSummary,
|
||||||
signS, signC,
|
signS, signC,
|
||||||
trackDist, trackMax, trackAvg, motorH,
|
trackDist, trackMax, trackAvg, motorH,
|
||||||
ev.time || '', ev.mgk || '', ev.rwk || '',
|
ev.time || '', creatorName, ev.mgk || '', ev.rwk || '',
|
||||||
ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '',
|
ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '',
|
||||||
|
ev.visibility || '',
|
||||||
ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '',
|
ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '',
|
||||||
ev.gpsLat || '', ev.gpsLng || '', ev.remarks || '',
|
ev.gpsLat || '', ev.gpsLng || '', ev.remarks || '',
|
||||||
fwM, fwR, fwE, fwCons,
|
fwM, fwR, fwE, fwCons,
|
||||||
|
|||||||
+154
-1
@@ -35,6 +35,14 @@ export interface LocalDeviation {
|
|||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EntryListCache {
|
||||||
|
date: string
|
||||||
|
dayOfTravel: string
|
||||||
|
departure: string
|
||||||
|
destination: string
|
||||||
|
skipperSignStatus: 'none' | 'valid' | 'invalid'
|
||||||
|
}
|
||||||
|
|
||||||
export interface LocalEntry {
|
export interface LocalEntry {
|
||||||
payloadId: string
|
payloadId: string
|
||||||
logbookId: string
|
logbookId: string
|
||||||
@@ -42,6 +50,8 @@ export interface LocalEntry {
|
|||||||
iv: string
|
iv: string
|
||||||
tag: string
|
tag: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
|
/** Plaintext list fields — avoids full decrypt when opening the journal list. */
|
||||||
|
listCache?: EntryListCache
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LocalPhoto {
|
export interface LocalPhoto {
|
||||||
@@ -55,6 +65,16 @@ export interface LocalPhoto {
|
|||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LocalVoiceMemo {
|
||||||
|
payloadId: string
|
||||||
|
entryId: string
|
||||||
|
logbookId: string
|
||||||
|
encryptedData: string
|
||||||
|
iv: string
|
||||||
|
tag: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface LocalGpsTrack {
|
export interface LocalGpsTrack {
|
||||||
entryId: string // one track per daily journal entry
|
entryId: string // one track per daily journal entry
|
||||||
logbookId: string
|
logbookId: string
|
||||||
@@ -80,16 +100,76 @@ export interface LocalLogbookKey {
|
|||||||
tag: string
|
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 {
|
export interface SyncQueueItem {
|
||||||
id?: number
|
id?: number
|
||||||
action: 'create' | 'update' | 'delete'
|
action: 'create' | 'update' | 'delete'
|
||||||
type: 'yacht' | 'crew' | 'deviation' | 'entry' | 'logbook' | 'photo' | 'gpsTrack'
|
type:
|
||||||
|
| 'yacht'
|
||||||
|
| 'crew'
|
||||||
|
| 'deviation'
|
||||||
|
| 'entry'
|
||||||
|
| 'logbook'
|
||||||
|
| 'photo'
|
||||||
|
| 'voiceMemo'
|
||||||
|
| 'gpsTrack'
|
||||||
|
| 'logbookCrew'
|
||||||
|
| 'logbookVessel'
|
||||||
payloadId: string // payloadId or logbookId depending on the type
|
payloadId: string // payloadId or logbookId depending on the type
|
||||||
logbookId: string
|
logbookId: string
|
||||||
data: string // JSON representation of the local record
|
data: string // JSON representation of the local record
|
||||||
updatedAt: string
|
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 {
|
class DaagboxDatabase extends Dexie {
|
||||||
logbooks!: Table<LocalLogbook>
|
logbooks!: Table<LocalLogbook>
|
||||||
yachts!: Table<LocalYacht>
|
yachts!: Table<LocalYacht>
|
||||||
@@ -97,10 +177,17 @@ class DaagboxDatabase extends Dexie {
|
|||||||
deviations!: Table<LocalDeviation>
|
deviations!: Table<LocalDeviation>
|
||||||
entries!: Table<LocalEntry>
|
entries!: Table<LocalEntry>
|
||||||
photos!: Table<LocalPhoto>
|
photos!: Table<LocalPhoto>
|
||||||
|
voiceMemos!: Table<LocalVoiceMemo>
|
||||||
gpsTracks!: Table<LocalGpsTrack>
|
gpsTracks!: Table<LocalGpsTrack>
|
||||||
nmeaArchives!: Table<LocalNmeaArchive>
|
nmeaArchives!: Table<LocalNmeaArchive>
|
||||||
logbookKeys!: Table<LocalLogbookKey>
|
logbookKeys!: Table<LocalLogbookKey>
|
||||||
|
personPool!: Table<LocalPerson>
|
||||||
|
vesselPool!: Table<LocalVessel>
|
||||||
|
logbookCrewSelections!: Table<LocalLogbookCrewSelection>
|
||||||
|
logbookVesselSelections!: Table<LocalLogbookVesselSelection>
|
||||||
syncQueue!: Table<SyncQueueItem>
|
syncQueue!: Table<SyncQueueItem>
|
||||||
|
userSyncQueue!: Table<UserSyncQueueItem>
|
||||||
|
entryDrafts!: Table<EntryDraftRecord, [string, string]>
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('DaagboxDatabase')
|
super('DaagboxDatabase')
|
||||||
@@ -167,6 +254,72 @@ class DaagboxDatabase extends Dexie {
|
|||||||
nmeaArchives: 'entryId, logbookId, updatedAt',
|
nmeaArchives: 'entryId, logbookId, updatedAt',
|
||||||
logbookKeys: 'logbookId'
|
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'
|
||||||
|
})
|
||||||
|
this.version(10).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',
|
||||||
|
voiceMemos: '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 { getLogbookKey } from './logbookKeys.js'
|
||||||
import { encryptJson } from './crypto.js'
|
import { encryptJson } from './crypto.js'
|
||||||
import { syncLogbook } from './sync.js'
|
import { syncLogbook } from './sync.js'
|
||||||
|
import { putEntryRecord } from '../utils/entryListCache.js'
|
||||||
|
import { syncPersonPool } from './personPoolSync.js'
|
||||||
import i18n from '../i18n/index.js'
|
import i18n from '../i18n/index.js'
|
||||||
|
import type { PersonData } from '../types/person.js'
|
||||||
|
import { buildLogbookCrewSelection } from '../utils/personSnapshots.js'
|
||||||
import {
|
import {
|
||||||
buildDemoCrewRecords,
|
buildDemoPersonPool,
|
||||||
buildDemoEntryPayloads,
|
buildDemoEntryPayloads,
|
||||||
buildDemoYachtData
|
buildDemoYachtData
|
||||||
} from './demoLogbookData.js'
|
} from './demoLogbookData.js'
|
||||||
@@ -24,7 +28,7 @@ export function getDemoFirstEntryStorageKey(userId: string): string {
|
|||||||
async function putEncryptedRecord(
|
async function putEncryptedRecord(
|
||||||
logbookId: string,
|
logbookId: string,
|
||||||
key: ArrayBuffer,
|
key: ArrayBuffer,
|
||||||
type: 'entry' | 'crew' | 'yacht' | 'gpsTrack',
|
type: 'entry' | 'yacht' | 'gpsTrack' | 'logbookCrew',
|
||||||
payloadId: string,
|
payloadId: string,
|
||||||
data: unknown,
|
data: unknown,
|
||||||
now: string
|
now: string
|
||||||
@@ -32,23 +36,17 @@ async function putEncryptedRecord(
|
|||||||
const encrypted = await encryptJson(data, key)
|
const encrypted = await encryptJson(data, key)
|
||||||
|
|
||||||
if (type === 'entry') {
|
if (type === 'entry') {
|
||||||
await db.entries.put({
|
await putEntryRecord(
|
||||||
payloadId,
|
{
|
||||||
logbookId,
|
payloadId,
|
||||||
encryptedData: encrypted.ciphertext,
|
logbookId,
|
||||||
iv: encrypted.iv,
|
encryptedData: encrypted.ciphertext,
|
||||||
tag: encrypted.tag,
|
iv: encrypted.iv,
|
||||||
updatedAt: now
|
tag: encrypted.tag,
|
||||||
})
|
updatedAt: now
|
||||||
} else if (type === 'crew') {
|
},
|
||||||
await db.crews.put({
|
data as Record<string, unknown>
|
||||||
payloadId,
|
)
|
||||||
logbookId,
|
|
||||||
encryptedData: encrypted.ciphertext,
|
|
||||||
iv: encrypted.iv,
|
|
||||||
tag: encrypted.tag,
|
|
||||||
updatedAt: now
|
|
||||||
})
|
|
||||||
} else if (type === 'yacht') {
|
} else if (type === 'yacht') {
|
||||||
await db.yachts.put({
|
await db.yachts.put({
|
||||||
logbookId,
|
logbookId,
|
||||||
@@ -66,25 +64,62 @@ async function putEncryptedRecord(
|
|||||||
tag: encrypted.tag,
|
tag: encrypted.tag,
|
||||||
updatedAt: now
|
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({
|
await db.syncQueue.put({
|
||||||
action: type === 'yacht' ? 'update' : 'create',
|
action: type === 'yacht' || type === 'logbookCrew' ? 'update' : 'create',
|
||||||
type,
|
type,
|
||||||
payloadId: type === 'yacht' ? logbookId : payloadId,
|
payloadId: type === 'yacht' || type === 'logbookCrew' ? logbookId : payloadId,
|
||||||
logbookId,
|
logbookId,
|
||||||
data: JSON.stringify(encrypted),
|
data: JSON.stringify(encrypted),
|
||||||
updatedAt: now
|
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> {
|
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()
|
const yachtData = buildDemoYachtData()
|
||||||
await putEncryptedRecord(logbookId, key, 'yacht', logbookId, yachtData, now)
|
await putEncryptedRecord(logbookId, key, 'yacht', logbookId, yachtData, now)
|
||||||
|
|
||||||
for (const crew of buildDemoCrewRecords()) {
|
const poolMap = await seedPersonPool(masterKey, now)
|
||||||
await putEncryptedRecord(logbookId, key, 'crew', crew.payloadId, crew.data, 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 {
|
export interface DemoSeedResult {
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ const PUBLIC_DEMO_ENTRY_IDS = [
|
|||||||
'a0000001-0000-4000-8000-000000000003'
|
'a0000001-0000-4000-8000-000000000003'
|
||||||
] as const
|
] 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'
|
const PUBLIC_DEMO_CREW_MEMBER_ID = 'a0000001-0000-4000-8000-000000000010'
|
||||||
|
|
||||||
export interface DemoDaySpec {
|
export interface DemoDaySpec {
|
||||||
@@ -49,10 +51,27 @@ export interface DemoCrewRecord {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DemoVesselRecord {
|
||||||
|
payloadId: string
|
||||||
|
data: Record<string, unknown> & { name: string }
|
||||||
|
}
|
||||||
|
|
||||||
export interface PublicDemoFixture {
|
export interface PublicDemoFixture {
|
||||||
title: string
|
title: string
|
||||||
yacht: Record<string, unknown>
|
yacht: Record<string, unknown>
|
||||||
|
vesselPool: DemoVesselRecord[]
|
||||||
|
logbookVesselSelection: {
|
||||||
|
activeVesselId: string | null
|
||||||
|
vesselSnapshot: Record<string, unknown> | null
|
||||||
|
}
|
||||||
|
/** @deprecated legacy share payload */
|
||||||
crews: DemoCrewRecord[]
|
crews: DemoCrewRecord[]
|
||||||
|
personPool: DemoCrewRecord[]
|
||||||
|
logbookCrewSelection: {
|
||||||
|
activeSkipperId: string
|
||||||
|
activeCrewIds: string[]
|
||||||
|
snapshotsById: Record<string, DemoCrewRecord['data'] & { id: string }>
|
||||||
|
}
|
||||||
entries: Array<Record<string, unknown> & { payloadId: string }>
|
entries: Array<Record<string, unknown> & { payloadId: string }>
|
||||||
gpsTracks: Array<{ entryId: string; waypoints: unknown[]; filename: string; gpxContent?: string; fileType: string }>
|
gpsTracks: Array<{ entryId: string; waypoints: unknown[]; filename: string; gpxContent?: string; fileType: string }>
|
||||||
photos: never[]
|
photos: never[]
|
||||||
@@ -188,11 +207,15 @@ export function buildDemoYachtData(): Record<string, unknown> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildDemoPersonPool(): DemoCrewRecord[] {
|
||||||
|
return buildDemoCrewRecords()
|
||||||
|
}
|
||||||
|
|
||||||
export function buildDemoCrewRecords(): DemoCrewRecord[] {
|
export function buildDemoCrewRecords(): DemoCrewRecord[] {
|
||||||
const isDe = isGermanLocale(i18n.language)
|
const isDe = isGermanLocale(i18n.language)
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
payloadId: 'skipper',
|
payloadId: PUBLIC_DEMO_SKIPPER_ID,
|
||||||
data: {
|
data: {
|
||||||
name: 'Demo Skipper',
|
name: 'Demo Skipper',
|
||||||
address: isDe ? 'Am Hafen 12, 24103 Kiel' : 'Harbour Quay 12, 24103 Kiel',
|
address: isDe ? 'Am Hafen 12, 24103 Kiel' : 'Harbour Quay 12, 24103 Kiel',
|
||||||
@@ -226,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 {
|
export function buildPublicDemoFixture(): PublicDemoFixture {
|
||||||
const title = i18n.t('demo.logbook_title')
|
const title = i18n.t('demo.logbook_title')
|
||||||
const yacht = buildDemoYachtData()
|
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 days = buildDemoDays()
|
||||||
const entries: PublicDemoFixture['entries'] = []
|
const entries: PublicDemoFixture['entries'] = []
|
||||||
const gpsTracks: PublicDemoFixture['gpsTracks'] = []
|
const gpsTracks: PublicDemoFixture['gpsTracks'] = []
|
||||||
@@ -247,6 +306,9 @@ export function buildPublicDemoFixture(): PublicDemoFixture {
|
|||||||
destination: day.destination,
|
destination: day.destination,
|
||||||
freshwater: { ...day.freshwater },
|
freshwater: { ...day.freshwater },
|
||||||
fuel: { ...day.fuel },
|
fuel: { ...day.fuel },
|
||||||
|
selectedSkipperId: logbookCrewSelection.activeSkipperId,
|
||||||
|
selectedCrewIds: [...logbookCrewSelection.activeCrewIds],
|
||||||
|
crewSnapshotsById: { ...logbookCrewSelection.snapshotsById },
|
||||||
signSkipper: '',
|
signSkipper: '',
|
||||||
signCrew: '',
|
signCrew: '',
|
||||||
events: day.events
|
events: day.events
|
||||||
@@ -279,7 +341,11 @@ export function buildPublicDemoFixture(): PublicDemoFixture {
|
|||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
yacht,
|
yacht,
|
||||||
|
vesselPool,
|
||||||
|
logbookVesselSelection,
|
||||||
crews,
|
crews,
|
||||||
|
personPool,
|
||||||
|
logbookCrewSelection,
|
||||||
entries,
|
entries,
|
||||||
gpsTracks,
|
gpsTracks,
|
||||||
photos: [],
|
photos: [],
|
||||||
@@ -297,6 +363,7 @@ export function buildDemoEntryPayloads(): Array<{
|
|||||||
entryPayload: Record<string, unknown>
|
entryPayload: Record<string, unknown>
|
||||||
trackData: { waypoints: unknown[]; gpxContent: string; filename: string; fileType: string }
|
trackData: { waypoints: unknown[]; gpxContent: string; filename: string; fileType: string }
|
||||||
}> {
|
}> {
|
||||||
|
const logbookCrewSelection = buildDemoLogbookCrewSelection(buildDemoPersonPool())
|
||||||
const days = buildDemoDays()
|
const days = buildDemoDays()
|
||||||
return days.map((day) => {
|
return days.map((day) => {
|
||||||
const entryId = crypto.randomUUID()
|
const entryId = crypto.randomUUID()
|
||||||
@@ -310,6 +377,9 @@ export function buildDemoEntryPayloads(): Array<{
|
|||||||
destination: day.destination,
|
destination: day.destination,
|
||||||
freshwater: { ...day.freshwater },
|
freshwater: { ...day.freshwater },
|
||||||
fuel: { ...day.fuel },
|
fuel: { ...day.fuel },
|
||||||
|
selectedSkipperId: logbookCrewSelection.activeSkipperId,
|
||||||
|
selectedCrewIds: [...logbookCrewSelection.activeCrewIds],
|
||||||
|
crewSnapshotsById: { ...logbookCrewSelection.snapshotsById },
|
||||||
signSkipper: '',
|
signSkipper: '',
|
||||||
signCrew: '',
|
signCrew: '',
|
||||||
events: day.events
|
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])
|
||||||
|
}
|
||||||
@@ -283,6 +283,7 @@ export async function deleteLocalLogbookCache(id: string): Promise<void> {
|
|||||||
await db.deviations.where({ logbookId: id }).delete()
|
await db.deviations.where({ logbookId: id }).delete()
|
||||||
await db.entries.where({ logbookId: id }).delete()
|
await db.entries.where({ logbookId: id }).delete()
|
||||||
await db.photos.where({ logbookId: id }).delete()
|
await db.photos.where({ logbookId: id }).delete()
|
||||||
|
await db.voiceMemos.where({ logbookId: id }).delete()
|
||||||
await db.gpsTracks.where({ logbookId: id }).delete()
|
await db.gpsTracks.where({ logbookId: id }).delete()
|
||||||
await db.syncQueue.where({ logbookId: id }).delete()
|
await db.syncQueue.where({ logbookId: id }).delete()
|
||||||
await db.logbookKeys.where({ logbookId: id }).delete()
|
await db.logbookKeys.where({ logbookId: id }).delete()
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { formatAppDecimal } from '../utils/numberFormat.js'
|
||||||
import { db } from './db.js'
|
import { db } from './db.js'
|
||||||
import { getActiveMasterKey } from './auth.js'
|
import { getActiveMasterKey } from './auth.js'
|
||||||
import {
|
import {
|
||||||
@@ -9,89 +10,54 @@ import { decryptLogbookTitle, deleteLocalLogbookCache } from './logbook.js'
|
|||||||
import { ensureLogbookKey, getLogbookKey, saveLogbookKey } from './logbookKeys.js'
|
import { ensureLogbookKey, getLogbookKey, saveLogbookKey } from './logbookKeys.js'
|
||||||
import { syncLogbook } from './sync.js'
|
import { syncLogbook } from './sync.js'
|
||||||
import type { SyncQueueItem } from './db.js'
|
import type { SyncQueueItem } from './db.js'
|
||||||
|
import { getAppVersion } from './pwaVersion.js'
|
||||||
|
import { dexieFieldsFromEncBytes, encBytesFromDexieFields } from './logbookBackup/encBlob.js'
|
||||||
|
import {
|
||||||
|
BACKUP_FORMAT,
|
||||||
|
BACKUP_VERSION,
|
||||||
|
type BackupManifestCounts,
|
||||||
|
type BackupManifestV2,
|
||||||
|
type LogbookMetaJson
|
||||||
|
} from './logbookBackup/manifest.js'
|
||||||
|
import {
|
||||||
|
buildArchiveFromCollected,
|
||||||
|
collectLogbookBackupData,
|
||||||
|
type BackupExportProgress
|
||||||
|
} from './logbookBackup/collector.js'
|
||||||
|
import {
|
||||||
|
isZipArchive,
|
||||||
|
readBinaryFile,
|
||||||
|
readManifestFromArchive,
|
||||||
|
readTextFile,
|
||||||
|
unzipArchive
|
||||||
|
} from './logbookBackup/zipArchive.js'
|
||||||
|
|
||||||
export const BACKUP_FORMAT = 'kapteins-daagbok-backup' as const
|
export { BACKUP_FORMAT, BACKUP_VERSION }
|
||||||
export const BACKUP_VERSION = 1 as const
|
export type { BackupExportProgress, BackupManifestCounts, BackupManifestV2 }
|
||||||
|
|
||||||
export interface LogbookBackupFile {
|
|
||||||
format: typeof BACKUP_FORMAT
|
|
||||||
version: typeof BACKUP_VERSION
|
|
||||||
exportedAt: string
|
|
||||||
logbook: {
|
|
||||||
id: string
|
|
||||||
encryptedTitle: string
|
|
||||||
updatedAt: string
|
|
||||||
isDemo?: boolean
|
|
||||||
}
|
|
||||||
logbookKey: {
|
|
||||||
ciphertext: string
|
|
||||||
iv: string
|
|
||||||
tag: string
|
|
||||||
}
|
|
||||||
payloads: {
|
|
||||||
yacht: {
|
|
||||||
encryptedData: string
|
|
||||||
iv: string
|
|
||||||
tag: string
|
|
||||||
updatedAt: string
|
|
||||||
} | null
|
|
||||||
deviation: {
|
|
||||||
encryptedData: string
|
|
||||||
iv: string
|
|
||||||
tag: string
|
|
||||||
updatedAt: string
|
|
||||||
} | null
|
|
||||||
crews: Array<{
|
|
||||||
payloadId: string
|
|
||||||
encryptedData: string
|
|
||||||
iv: string
|
|
||||||
tag: string
|
|
||||||
updatedAt: string
|
|
||||||
}>
|
|
||||||
entries: Array<{
|
|
||||||
payloadId: string
|
|
||||||
encryptedData: string
|
|
||||||
iv: string
|
|
||||||
tag: string
|
|
||||||
updatedAt: string
|
|
||||||
}>
|
|
||||||
photos: Array<{
|
|
||||||
payloadId: string
|
|
||||||
entryId: string
|
|
||||||
encryptedData: string
|
|
||||||
iv: string
|
|
||||||
tag: string
|
|
||||||
updatedAt: string
|
|
||||||
}>
|
|
||||||
gpsTracks: Array<{
|
|
||||||
entryId: string
|
|
||||||
encryptedData: string
|
|
||||||
iv: string
|
|
||||||
tag: string
|
|
||||||
updatedAt: string
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
counts: {
|
|
||||||
entries: number
|
|
||||||
photos: number
|
|
||||||
crews: number
|
|
||||||
gpsTracks: number
|
|
||||||
hasYacht: boolean
|
|
||||||
hasDeviation: boolean
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LogbookBackupPreview {
|
export interface LogbookBackupPreview {
|
||||||
title: string
|
title: string
|
||||||
exportedAt: string
|
exportedAt: string
|
||||||
sourceLogbookId: string
|
sourceLogbookId: string
|
||||||
counts: LogbookBackupFile['counts']
|
counts: BackupManifestCounts
|
||||||
|
totalUncompressedBytes: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ParsedLogbookBackup {
|
||||||
|
manifest: BackupManifestV2
|
||||||
|
files: Record<string, Uint8Array>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportLogbookBackupOptions {
|
||||||
|
onProgress?: (progress: BackupExportProgress) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const BACKUP_PASSPHRASE_SALT = 'KapteinsDaagbokBackupFileSalt_v1'
|
||||||
|
|
||||||
async function deriveBackupPassphraseKey(passphrase: string): Promise<CryptoKey> {
|
async function deriveBackupPassphraseKey(passphrase: string): Promise<CryptoKey> {
|
||||||
const encoder = new TextEncoder()
|
const encoder = new TextEncoder()
|
||||||
const passphraseBytes = encoder.encode(passphrase.trim())
|
const passphraseBytes = encoder.encode(passphrase.trim())
|
||||||
const saltBytes = encoder.encode('KapteinsDaagbokBackupFileSalt_v1')
|
const saltBytes = encoder.encode(BACKUP_PASSPHRASE_SALT)
|
||||||
|
|
||||||
const baseKey = await window.crypto.subtle.importKey(
|
const baseKey = await window.crypto.subtle.importKey(
|
||||||
'raw',
|
'raw',
|
||||||
@@ -120,26 +86,17 @@ async function wrapLogbookKey(logbookKey: ArrayBuffer, passphrase: string) {
|
|||||||
return encryptBuffer(logbookKey, key)
|
return encryptBuffer(logbookKey, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function unwrapLogbookKey(
|
async function unwrapLogbookKeyFromEnc(
|
||||||
wrapped: LogbookBackupFile['logbookKey'],
|
keyEnc: Uint8Array,
|
||||||
passphrase: string
|
passphrase: string
|
||||||
): Promise<ArrayBuffer> {
|
): Promise<ArrayBuffer> {
|
||||||
const key = await deriveBackupPassphraseKey(passphrase)
|
try {
|
||||||
return decryptBuffer(wrapped.ciphertext, wrapped.iv, wrapped.tag, key)
|
const fields = dexieFieldsFromEncBytes(keyEnc)
|
||||||
}
|
const cryptoKey = await deriveBackupPassphraseKey(passphrase)
|
||||||
|
return decryptBuffer(fields.encryptedData, fields.iv, fields.tag, cryptoKey)
|
||||||
function isBackupFile(value: unknown): value is LogbookBackupFile {
|
} catch {
|
||||||
if (!value || typeof value !== 'object') return false
|
throw new Error('BACKUP_WRONG_PASSPHRASE')
|
||||||
const obj = value as Partial<LogbookBackupFile>
|
}
|
||||||
return (
|
|
||||||
obj.format === BACKUP_FORMAT &&
|
|
||||||
obj.version === BACKUP_VERSION &&
|
|
||||||
typeof obj.exportedAt === 'string' &&
|
|
||||||
!!obj.logbook?.id &&
|
|
||||||
!!obj.logbook?.encryptedTitle &&
|
|
||||||
!!obj.logbookKey?.ciphertext &&
|
|
||||||
!!obj.payloads
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function encryptedPayloadData(
|
function encryptedPayloadData(
|
||||||
@@ -156,96 +113,12 @@ function encryptedPayloadData(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function collectLogbookPayloads(logbookId: string): Promise<LogbookBackupFile['payloads']> {
|
|
||||||
const [yacht, deviation, crews, entries, photos, gpsTracks] = await Promise.all([
|
|
||||||
db.yachts.get(logbookId),
|
|
||||||
db.deviations.get(logbookId),
|
|
||||||
db.crews.where({ logbookId }).toArray(),
|
|
||||||
db.entries.where({ logbookId }).toArray(),
|
|
||||||
db.photos.where({ logbookId }).toArray(),
|
|
||||||
db.gpsTracks.where({ logbookId }).toArray()
|
|
||||||
])
|
|
||||||
|
|
||||||
return {
|
|
||||||
yacht: yacht
|
|
||||||
? {
|
|
||||||
encryptedData: yacht.encryptedData,
|
|
||||||
iv: yacht.iv,
|
|
||||||
tag: yacht.tag,
|
|
||||||
updatedAt: yacht.updatedAt
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
deviation: deviation
|
|
||||||
? {
|
|
||||||
encryptedData: deviation.encryptedData,
|
|
||||||
iv: deviation.iv,
|
|
||||||
tag: deviation.tag,
|
|
||||||
updatedAt: deviation.updatedAt
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
crews: crews.map((c) => ({
|
|
||||||
payloadId: c.payloadId,
|
|
||||||
encryptedData: c.encryptedData,
|
|
||||||
iv: c.iv,
|
|
||||||
tag: c.tag,
|
|
||||||
updatedAt: c.updatedAt
|
|
||||||
})),
|
|
||||||
entries: entries.map((e) => ({
|
|
||||||
payloadId: e.payloadId,
|
|
||||||
encryptedData: e.encryptedData,
|
|
||||||
iv: e.iv,
|
|
||||||
tag: e.tag,
|
|
||||||
updatedAt: e.updatedAt
|
|
||||||
})),
|
|
||||||
photos: photos.map((p) => ({
|
|
||||||
payloadId: p.payloadId,
|
|
||||||
entryId: p.entryId,
|
|
||||||
encryptedData: p.encryptedData,
|
|
||||||
iv: p.iv,
|
|
||||||
tag: p.tag,
|
|
||||||
updatedAt: p.updatedAt
|
|
||||||
})),
|
|
||||||
gpsTracks: gpsTracks.map((t) => ({
|
|
||||||
entryId: t.entryId,
|
|
||||||
encryptedData: t.encryptedData,
|
|
||||||
iv: t.iv,
|
|
||||||
tag: t.tag,
|
|
||||||
updatedAt: t.updatedAt
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function remapBackup(
|
|
||||||
backup: LogbookBackupFile,
|
|
||||||
newLogbookId: string
|
|
||||||
): LogbookBackupFile {
|
|
||||||
return {
|
|
||||||
...backup,
|
|
||||||
logbook: {
|
|
||||||
...backup.logbook,
|
|
||||||
id: newLogbookId
|
|
||||||
},
|
|
||||||
payloads: {
|
|
||||||
...backup.payloads,
|
|
||||||
yacht: backup.payloads.yacht
|
|
||||||
? { ...backup.payloads.yacht, updatedAt: backup.payloads.yacht.updatedAt }
|
|
||||||
: null,
|
|
||||||
deviation: backup.payloads.deviation
|
|
||||||
? { ...backup.payloads.deviation, updatedAt: backup.payloads.deviation.updatedAt }
|
|
||||||
: null,
|
|
||||||
crews: backup.payloads.crews.map((c) => ({ ...c })),
|
|
||||||
entries: backup.payloads.entries.map((e) => ({ ...e })),
|
|
||||||
photos: backup.payloads.photos.map((p) => ({ ...p })),
|
|
||||||
gpsTracks: backup.payloads.gpsTracks.map((t) => ({ ...t }))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function queueRestoredLogbookForSync(
|
async function queueRestoredLogbookForSync(
|
||||||
logbookId: string,
|
logbookId: string,
|
||||||
encryptedTitle: string,
|
encryptedTitle: string,
|
||||||
logbookKey: ArrayBuffer,
|
logbookKey: ArrayBuffer,
|
||||||
payloads: LogbookBackupFile['payloads']
|
manifest: BackupManifestV2,
|
||||||
|
files: Record<string, Uint8Array>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const masterKey = getActiveMasterKey()
|
const masterKey = getActiveMasterKey()
|
||||||
if (!masterKey) throw new Error('Master key not found')
|
if (!masterKey) throw new Error('Master key not found')
|
||||||
@@ -276,78 +149,123 @@ async function queueRestoredLogbookForSync(
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
if (payloads.yacht) {
|
const readFields = (path: string | null) => {
|
||||||
|
if (!path) return null
|
||||||
|
return dexieFieldsFromEncBytes(readBinaryFile(files, path))
|
||||||
|
}
|
||||||
|
|
||||||
|
const yacht = readFields(manifest.files.yacht)
|
||||||
|
if (yacht) {
|
||||||
items.push({
|
items.push({
|
||||||
action: 'update',
|
action: 'update',
|
||||||
type: 'yacht',
|
type: 'yacht',
|
||||||
payloadId: logbookId,
|
payloadId: logbookId,
|
||||||
logbookId,
|
logbookId,
|
||||||
data: encryptedPayloadData(
|
data: encryptedPayloadData(yacht.encryptedData, yacht.iv, yacht.tag),
|
||||||
payloads.yacht.encryptedData,
|
updatedAt: now
|
||||||
payloads.yacht.iv,
|
|
||||||
payloads.yacht.tag
|
|
||||||
),
|
|
||||||
updatedAt: payloads.yacht.updatedAt
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payloads.deviation) {
|
const deviation = readFields(manifest.files.deviation)
|
||||||
|
if (deviation) {
|
||||||
items.push({
|
items.push({
|
||||||
action: 'update',
|
action: 'update',
|
||||||
type: 'deviation',
|
type: 'deviation',
|
||||||
payloadId: logbookId,
|
payloadId: logbookId,
|
||||||
logbookId,
|
logbookId,
|
||||||
data: encryptedPayloadData(
|
data: encryptedPayloadData(deviation.encryptedData, deviation.iv, deviation.tag),
|
||||||
payloads.deviation.encryptedData,
|
updatedAt: now
|
||||||
payloads.deviation.iv,
|
|
||||||
payloads.deviation.tag
|
|
||||||
),
|
|
||||||
updatedAt: payloads.deviation.updatedAt
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const crew of payloads.crews) {
|
const logbookCrew = readFields(manifest.files.logbookCrewSelection)
|
||||||
|
if (logbookCrew) {
|
||||||
|
items.push({
|
||||||
|
action: 'update',
|
||||||
|
type: 'logbookCrew',
|
||||||
|
payloadId: logbookId,
|
||||||
|
logbookId,
|
||||||
|
data: encryptedPayloadData(logbookCrew.encryptedData, logbookCrew.iv, logbookCrew.tag),
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const logbookVessel = readFields(manifest.files.logbookVesselSelection)
|
||||||
|
if (logbookVessel) {
|
||||||
|
items.push({
|
||||||
|
action: 'update',
|
||||||
|
type: 'logbookVessel',
|
||||||
|
payloadId: logbookId,
|
||||||
|
logbookId,
|
||||||
|
data: encryptedPayloadData(
|
||||||
|
logbookVessel.encryptedData,
|
||||||
|
logbookVessel.iv,
|
||||||
|
logbookVessel.tag
|
||||||
|
),
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const crew of manifest.files.crews) {
|
||||||
|
const f = readFields(crew.path)
|
||||||
items.push({
|
items.push({
|
||||||
action: 'create',
|
action: 'create',
|
||||||
type: 'crew',
|
type: 'crew',
|
||||||
payloadId: crew.payloadId,
|
payloadId: crew.payloadId,
|
||||||
logbookId,
|
logbookId,
|
||||||
data: encryptedPayloadData(crew.encryptedData, crew.iv, crew.tag),
|
data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag),
|
||||||
updatedAt: crew.updatedAt
|
updatedAt: crew.updatedAt
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const entry of payloads.entries) {
|
for (const entry of manifest.files.entries) {
|
||||||
|
const f = readFields(entry.path)
|
||||||
items.push({
|
items.push({
|
||||||
action: 'create',
|
action: 'create',
|
||||||
type: 'entry',
|
type: 'entry',
|
||||||
payloadId: entry.payloadId,
|
payloadId: entry.payloadId,
|
||||||
logbookId,
|
logbookId,
|
||||||
data: encryptedPayloadData(entry.encryptedData, entry.iv, entry.tag),
|
data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag),
|
||||||
updatedAt: entry.updatedAt
|
updatedAt: entry.updatedAt
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const photo of payloads.photos) {
|
for (const photo of manifest.files.photos) {
|
||||||
|
const f = readFields(photo.path)
|
||||||
items.push({
|
items.push({
|
||||||
action: 'create',
|
action: 'create',
|
||||||
type: 'photo',
|
type: 'photo',
|
||||||
payloadId: photo.payloadId,
|
payloadId: photo.payloadId,
|
||||||
logbookId,
|
logbookId,
|
||||||
data: encryptedPayloadData(photo.encryptedData, photo.iv, photo.tag, {
|
data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag, {
|
||||||
entryId: photo.entryId
|
entryId: photo.entryId
|
||||||
}),
|
}),
|
||||||
updatedAt: photo.updatedAt
|
updatedAt: photo.updatedAt
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const track of payloads.gpsTracks) {
|
for (const voice of manifest.files.voiceMemos) {
|
||||||
|
const f = readFields(voice.path)
|
||||||
|
items.push({
|
||||||
|
action: 'create',
|
||||||
|
type: 'voiceMemo',
|
||||||
|
payloadId: voice.payloadId,
|
||||||
|
logbookId,
|
||||||
|
data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag, {
|
||||||
|
entryId: voice.entryId
|
||||||
|
}),
|
||||||
|
updatedAt: voice.updatedAt
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const track of manifest.files.gpsTracks) {
|
||||||
|
const f = readFields(track.path)
|
||||||
items.push({
|
items.push({
|
||||||
action: 'create',
|
action: 'create',
|
||||||
type: 'gpsTrack',
|
type: 'gpsTrack',
|
||||||
payloadId: track.entryId,
|
payloadId: track.entryId,
|
||||||
logbookId,
|
logbookId,
|
||||||
data: encryptedPayloadData(track.encryptedData, track.iv, track.tag),
|
data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag),
|
||||||
updatedAt: track.updatedAt
|
updatedAt: track.updatedAt
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -357,101 +275,190 @@ async function queueRestoredLogbookForSync(
|
|||||||
|
|
||||||
async function writeBackupToDexie(
|
async function writeBackupToDexie(
|
||||||
logbookId: string,
|
logbookId: string,
|
||||||
backup: LogbookBackupFile,
|
logbookMeta: LogbookMetaJson,
|
||||||
logbookKey: ArrayBuffer
|
logbookKey: ArrayBuffer,
|
||||||
|
manifest: BackupManifestV2,
|
||||||
|
files: Record<string, Uint8Array>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { logbook, payloads } = backup
|
|
||||||
|
|
||||||
await db.logbooks.put({
|
await db.logbooks.put({
|
||||||
id: logbookId,
|
id: logbookId,
|
||||||
encryptedTitle: logbook.encryptedTitle,
|
encryptedTitle: logbookMeta.encryptedTitle,
|
||||||
updatedAt: logbook.updatedAt,
|
updatedAt: logbookMeta.updatedAt,
|
||||||
isSynced: 0,
|
isSynced: 0,
|
||||||
isShared: 0,
|
isShared: 0,
|
||||||
isDemo: logbook.isDemo ? 1 : 0
|
isDemo: logbookMeta.isDemo ? 1 : 0
|
||||||
})
|
})
|
||||||
|
|
||||||
await saveLogbookKey(logbookId, logbookKey)
|
await saveLogbookKey(logbookId, logbookKey)
|
||||||
|
|
||||||
if (payloads.yacht) {
|
const readFields = (path: string | null) => {
|
||||||
|
if (!path) return null
|
||||||
|
return dexieFieldsFromEncBytes(readBinaryFile(files, path))
|
||||||
|
}
|
||||||
|
|
||||||
|
const yacht = readFields(manifest.files.yacht)
|
||||||
|
if (yacht) {
|
||||||
await db.yachts.put({
|
await db.yachts.put({
|
||||||
logbookId,
|
logbookId,
|
||||||
encryptedData: payloads.yacht.encryptedData,
|
encryptedData: yacht.encryptedData,
|
||||||
iv: payloads.yacht.iv,
|
iv: yacht.iv,
|
||||||
tag: payloads.yacht.tag,
|
tag: yacht.tag,
|
||||||
updatedAt: payloads.yacht.updatedAt
|
updatedAt: logbookMeta.updatedAt
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payloads.deviation) {
|
const deviation = readFields(manifest.files.deviation)
|
||||||
|
if (deviation) {
|
||||||
await db.deviations.put({
|
await db.deviations.put({
|
||||||
logbookId,
|
logbookId,
|
||||||
encryptedData: payloads.deviation.encryptedData,
|
encryptedData: deviation.encryptedData,
|
||||||
iv: payloads.deviation.iv,
|
iv: deviation.iv,
|
||||||
tag: payloads.deviation.tag,
|
tag: deviation.tag,
|
||||||
updatedAt: payloads.deviation.updatedAt
|
updatedAt: logbookMeta.updatedAt
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payloads.crews.length > 0) {
|
const logbookCrew = readFields(manifest.files.logbookCrewSelection)
|
||||||
|
if (logbookCrew) {
|
||||||
|
await db.logbookCrewSelections.put({
|
||||||
|
logbookId,
|
||||||
|
encryptedData: logbookCrew.encryptedData,
|
||||||
|
iv: logbookCrew.iv,
|
||||||
|
tag: logbookCrew.tag,
|
||||||
|
updatedAt: logbookMeta.updatedAt
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const logbookVessel = readFields(manifest.files.logbookVesselSelection)
|
||||||
|
if (logbookVessel) {
|
||||||
|
await db.logbookVesselSelections.put({
|
||||||
|
logbookId,
|
||||||
|
encryptedData: logbookVessel.encryptedData,
|
||||||
|
iv: logbookVessel.iv,
|
||||||
|
tag: logbookVessel.tag,
|
||||||
|
updatedAt: logbookMeta.updatedAt
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manifest.files.crews.length > 0) {
|
||||||
await db.crews.bulkPut(
|
await db.crews.bulkPut(
|
||||||
payloads.crews.map((c) => ({
|
manifest.files.crews.map((c) => {
|
||||||
payloadId: c.payloadId,
|
const f = dexieFieldsFromEncBytes(readBinaryFile(files, c.path))
|
||||||
logbookId,
|
return {
|
||||||
encryptedData: c.encryptedData,
|
payloadId: c.payloadId,
|
||||||
iv: c.iv,
|
logbookId,
|
||||||
tag: c.tag,
|
encryptedData: f.encryptedData,
|
||||||
updatedAt: c.updatedAt
|
iv: f.iv,
|
||||||
}))
|
tag: f.tag,
|
||||||
|
updatedAt: c.updatedAt
|
||||||
|
}
|
||||||
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payloads.entries.length > 0) {
|
if (manifest.files.entries.length > 0) {
|
||||||
await db.entries.bulkPut(
|
await db.entries.bulkPut(
|
||||||
payloads.entries.map((e) => ({
|
manifest.files.entries.map((e) => {
|
||||||
payloadId: e.payloadId,
|
const f = dexieFieldsFromEncBytes(readBinaryFile(files, e.path))
|
||||||
logbookId,
|
return {
|
||||||
encryptedData: e.encryptedData,
|
payloadId: e.payloadId,
|
||||||
iv: e.iv,
|
logbookId,
|
||||||
tag: e.tag,
|
encryptedData: f.encryptedData,
|
||||||
updatedAt: e.updatedAt
|
iv: f.iv,
|
||||||
}))
|
tag: f.tag,
|
||||||
|
updatedAt: e.updatedAt
|
||||||
|
}
|
||||||
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payloads.photos.length > 0) {
|
if (manifest.files.photos.length > 0) {
|
||||||
await db.photos.bulkPut(
|
await db.photos.bulkPut(
|
||||||
payloads.photos.map((p) => ({
|
manifest.files.photos.map((p) => {
|
||||||
payloadId: p.payloadId,
|
const f = dexieFieldsFromEncBytes(readBinaryFile(files, p.path))
|
||||||
entryId: p.entryId,
|
return {
|
||||||
logbookId,
|
payloadId: p.payloadId,
|
||||||
encryptedData: p.encryptedData,
|
entryId: p.entryId,
|
||||||
iv: p.iv,
|
logbookId,
|
||||||
tag: p.tag,
|
encryptedData: f.encryptedData,
|
||||||
caption: '',
|
iv: f.iv,
|
||||||
updatedAt: p.updatedAt
|
tag: f.tag,
|
||||||
}))
|
caption: '',
|
||||||
|
updatedAt: p.updatedAt
|
||||||
|
}
|
||||||
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payloads.gpsTracks.length > 0) {
|
if (manifest.files.voiceMemos.length > 0) {
|
||||||
await db.gpsTracks.bulkPut(
|
await db.voiceMemos.bulkPut(
|
||||||
payloads.gpsTracks.map((t) => ({
|
manifest.files.voiceMemos.map((v) => {
|
||||||
entryId: t.entryId,
|
const f = dexieFieldsFromEncBytes(readBinaryFile(files, v.path))
|
||||||
logbookId,
|
return {
|
||||||
encryptedData: t.encryptedData,
|
payloadId: v.payloadId,
|
||||||
iv: t.iv,
|
entryId: v.entryId,
|
||||||
tag: t.tag,
|
logbookId,
|
||||||
updatedAt: t.updatedAt
|
encryptedData: f.encryptedData,
|
||||||
}))
|
iv: f.iv,
|
||||||
|
tag: f.tag,
|
||||||
|
updatedAt: v.updatedAt
|
||||||
|
}
|
||||||
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (manifest.files.gpsTracks.length > 0) {
|
||||||
|
await db.gpsTracks.bulkPut(
|
||||||
|
manifest.files.gpsTracks.map((t) => {
|
||||||
|
const f = dexieFieldsFromEncBytes(readBinaryFile(files, t.path))
|
||||||
|
return {
|
||||||
|
entryId: t.entryId,
|
||||||
|
logbookId,
|
||||||
|
encryptedData: f.encryptedData,
|
||||||
|
iv: f.iv,
|
||||||
|
tag: f.tag,
|
||||||
|
updatedAt: t.updatedAt
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manifest.files.nmeaArchives.length > 0) {
|
||||||
|
await db.nmeaArchives.bulkPut(
|
||||||
|
manifest.files.nmeaArchives.map((n) => {
|
||||||
|
const f = dexieFieldsFromEncBytes(readBinaryFile(files, n.path))
|
||||||
|
return {
|
||||||
|
entryId: n.entryId,
|
||||||
|
logbookId,
|
||||||
|
encryptedData: f.encryptedData,
|
||||||
|
iv: f.iv,
|
||||||
|
tag: f.tag,
|
||||||
|
updatedAt: n.updatedAt
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function remapParsedBackup(
|
||||||
|
parsed: ParsedLogbookBackup,
|
||||||
|
newLogbookId: string
|
||||||
|
): ParsedLogbookBackup {
|
||||||
|
const logbookMeta = JSON.parse(readTextFile(parsed.files, parsed.manifest.files.logbook)) as LogbookMetaJson
|
||||||
|
logbookMeta.id = newLogbookId
|
||||||
|
const newFiles = { ...parsed.files }
|
||||||
|
newFiles[parsed.manifest.files.logbook] = new TextEncoder().encode(JSON.stringify(logbookMeta))
|
||||||
|
return {
|
||||||
|
manifest: { ...parsed.manifest, logbookId: newLogbookId },
|
||||||
|
files: newFiles
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function exportLogbookBackup(
|
export async function exportLogbookBackup(
|
||||||
logbookId: string,
|
logbookId: string,
|
||||||
passphrase: string
|
passphrase: string,
|
||||||
): Promise<{ blob: Blob; filename: string; backup: LogbookBackupFile }> {
|
options: ExportLogbookBackupOptions = {}
|
||||||
|
): Promise<{ blob: Blob; filename: string; manifest: BackupManifestV2 }> {
|
||||||
if (!passphrase.trim() || passphrase.length < 8) {
|
if (!passphrase.trim() || passphrase.length < 8) {
|
||||||
throw new Error('BACKUP_PASSPHRASE_TOO_SHORT')
|
throw new Error('BACKUP_PASSPHRASE_TOO_SHORT')
|
||||||
}
|
}
|
||||||
@@ -467,70 +474,84 @@ export async function exportLogbookBackup(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
options.onProgress?.({ phase: 'collect', current: 0, total: 1, bytesPacked: 0 })
|
||||||
|
const collected = await collectLogbookBackupData(logbookId)
|
||||||
const logbookKey = (await getLogbookKey(logbookId)) ?? (await ensureLogbookKey(logbookId))
|
const logbookKey = (await getLogbookKey(logbookId)) ?? (await ensureLogbookKey(logbookId))
|
||||||
const payloads = await collectLogbookPayloads(logbookId)
|
const wrapped = await wrapLogbookKey(logbookKey, passphrase)
|
||||||
const wrappedKey = await wrapLogbookKey(logbookKey, passphrase)
|
const keyEnc = encBytesFromDexieFields({
|
||||||
|
encryptedData: wrapped.ciphertext,
|
||||||
|
iv: wrapped.iv,
|
||||||
|
tag: wrapped.tag
|
||||||
|
})
|
||||||
|
|
||||||
const backup: LogbookBackupFile = {
|
const { zipBytes, manifest } = buildArchiveFromCollected(collected, keyEnc, {
|
||||||
format: BACKUP_FORMAT,
|
|
||||||
version: BACKUP_VERSION,
|
|
||||||
exportedAt: new Date().toISOString(),
|
exportedAt: new Date().toISOString(),
|
||||||
logbook: {
|
appVersion: getAppVersion(),
|
||||||
id: logbook.id,
|
onProgress: options.onProgress
|
||||||
encryptedTitle: logbook.encryptedTitle,
|
})
|
||||||
updatedAt: logbook.updatedAt,
|
|
||||||
isDemo: logbook.isDemo === 1
|
|
||||||
},
|
|
||||||
logbookKey: wrappedKey,
|
|
||||||
payloads,
|
|
||||||
counts: {
|
|
||||||
entries: payloads.entries.length,
|
|
||||||
photos: payloads.photos.length,
|
|
||||||
crews: payloads.crews.length,
|
|
||||||
gpsTracks: payloads.gpsTracks.length,
|
|
||||||
hasYacht: !!payloads.yacht,
|
|
||||||
hasDeviation: !!payloads.deviation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const title = await decryptLogbookTitle(logbookId, logbook.encryptedTitle)
|
const title = await decryptLogbookTitle(logbookId, logbook.encryptedTitle)
|
||||||
const safeTitle = title.replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-').slice(0, 40) || 'logbook'
|
const safeTitle = title.replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-').slice(0, 40) || 'logbook'
|
||||||
const datePart = new Date().toISOString().slice(0, 10)
|
const datePart = new Date().toISOString().slice(0, 10)
|
||||||
const filename = `${safeTitle}-${datePart}.daagbok.json`
|
const filename = `${safeTitle}-${datePart}.daagbok`
|
||||||
const blob = new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' })
|
const blob = new Blob([zipBytes.slice()], { type: 'application/zip' })
|
||||||
|
|
||||||
return { blob, filename, backup }
|
return { blob, filename, manifest }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function parseLogbookBackupFile(file: File): Promise<LogbookBackupFile> {
|
function detectLegacyJsonV1(text: string): boolean {
|
||||||
const text = await file.text()
|
const trimmed = text.trimStart()
|
||||||
let parsed: unknown
|
if (!trimmed.startsWith('{')) return false
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(text)
|
const parsed = JSON.parse(trimmed) as { format?: string; version?: number }
|
||||||
|
return parsed.format === BACKUP_FORMAT && parsed.version === 1
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error('BACKUP_INVALID_JSON')
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseLogbookBackupFile(file: File): Promise<ParsedLogbookBackup> {
|
||||||
|
const buffer = await file.arrayBuffer()
|
||||||
|
const bytes = new Uint8Array(buffer)
|
||||||
|
|
||||||
|
if (!isZipArchive(bytes)) {
|
||||||
|
const text = new TextDecoder().decode(bytes)
|
||||||
|
if (detectLegacyJsonV1(text)) {
|
||||||
|
throw new Error('BACKUP_VERSION_UNSUPPORTED')
|
||||||
|
}
|
||||||
|
throw new Error('BACKUP_INVALID_ARCHIVE')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isBackupFile(parsed)) {
|
const files = unzipArchive(bytes)
|
||||||
throw new Error('BACKUP_INVALID_FORMAT')
|
const manifest = readManifestFromArchive(files)
|
||||||
}
|
return { manifest, files }
|
||||||
|
|
||||||
return parsed
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function previewLogbookBackup(
|
export async function previewLogbookBackup(
|
||||||
backup: LogbookBackupFile,
|
backup: ParsedLogbookBackup,
|
||||||
passphrase: string
|
passphrase: string
|
||||||
): Promise<LogbookBackupPreview> {
|
): Promise<LogbookBackupPreview> {
|
||||||
const logbookKey = await unwrapLogbookKey(backup.logbookKey, passphrase)
|
const logbookKey = await unwrapLogbookKeyFromEnc(
|
||||||
const parsed = JSON.parse(backup.logbook.encryptedTitle)
|
readBinaryFile(backup.files, backup.manifest.files.key),
|
||||||
const title = await decryptJson(parsed.ciphertext, parsed.iv, parsed.tag, logbookKey)
|
passphrase
|
||||||
|
)
|
||||||
|
const logbookMeta = JSON.parse(
|
||||||
|
readTextFile(backup.files, backup.manifest.files.logbook)
|
||||||
|
) as LogbookMetaJson
|
||||||
|
const parsed = JSON.parse(logbookMeta.encryptedTitle)
|
||||||
|
let title: string
|
||||||
|
try {
|
||||||
|
title = await decryptJson(parsed.ciphertext, parsed.iv, parsed.tag, logbookKey)
|
||||||
|
} catch {
|
||||||
|
throw new Error('BACKUP_WRONG_PASSPHRASE')
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
exportedAt: backup.exportedAt,
|
exportedAt: backup.manifest.exportedAt,
|
||||||
sourceLogbookId: backup.logbook.id,
|
sourceLogbookId: backup.manifest.logbookId,
|
||||||
counts: backup.counts
|
counts: backup.manifest.counts,
|
||||||
|
totalUncompressedBytes: backup.manifest.totalUncompressedBytes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,7 +561,7 @@ export interface RestoreLogbookOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function restoreLogbookBackup(
|
export async function restoreLogbookBackup(
|
||||||
backup: LogbookBackupFile,
|
backup: ParsedLogbookBackup,
|
||||||
passphrase: string,
|
passphrase: string,
|
||||||
options: RestoreLogbookOptions = {}
|
options: RestoreLogbookOptions = {}
|
||||||
): Promise<{ logbookId: string; title: string }> {
|
): Promise<{ logbookId: string; title: string }> {
|
||||||
@@ -548,16 +569,22 @@ export async function restoreLogbookBackup(
|
|||||||
throw new Error('BACKUP_NOT_AUTHENTICATED')
|
throw new Error('BACKUP_NOT_AUTHENTICATED')
|
||||||
}
|
}
|
||||||
|
|
||||||
const logbookKey = await unwrapLogbookKey(backup.logbookKey, passphrase)
|
const logbookKey = await unwrapLogbookKeyFromEnc(
|
||||||
const parsedTitle = JSON.parse(backup.logbook.encryptedTitle)
|
readBinaryFile(backup.files, backup.manifest.files.key),
|
||||||
const title = await decryptJson(
|
passphrase
|
||||||
parsedTitle.ciphertext,
|
|
||||||
parsedTitle.iv,
|
|
||||||
parsedTitle.tag,
|
|
||||||
logbookKey
|
|
||||||
)
|
)
|
||||||
|
const logbookMeta = JSON.parse(
|
||||||
|
readTextFile(backup.files, backup.manifest.files.logbook)
|
||||||
|
) as LogbookMetaJson
|
||||||
|
const parsedTitle = JSON.parse(logbookMeta.encryptedTitle)
|
||||||
|
let title: string
|
||||||
|
try {
|
||||||
|
title = await decryptJson(parsedTitle.ciphertext, parsedTitle.iv, parsedTitle.tag, logbookKey)
|
||||||
|
} catch {
|
||||||
|
throw new Error('BACKUP_WRONG_PASSPHRASE')
|
||||||
|
}
|
||||||
|
|
||||||
let targetId = backup.logbook.id
|
let targetId = backup.manifest.logbookId
|
||||||
const existing = await db.logbooks.get(targetId)
|
const existing = await db.logbooks.get(targetId)
|
||||||
|
|
||||||
if (existing && !options.overwrite && !options.assignNewId) {
|
if (existing && !options.overwrite && !options.assignNewId) {
|
||||||
@@ -568,18 +595,29 @@ export async function restoreLogbookBackup(
|
|||||||
await deleteLocalLogbookCache(targetId)
|
await deleteLocalLogbookCache(targetId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let prepared = backup
|
||||||
if (options.assignNewId || (existing && !options.overwrite)) {
|
if (options.assignNewId || (existing && !options.overwrite)) {
|
||||||
targetId = crypto.randomUUID()
|
targetId = crypto.randomUUID()
|
||||||
|
prepared = remapParsedBackup(backup, targetId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const prepared = targetId === backup.logbook.id ? backup : remapBackup(backup, targetId)
|
const finalMeta = JSON.parse(
|
||||||
|
readTextFile(prepared.files, prepared.manifest.files.logbook)
|
||||||
|
) as LogbookMetaJson
|
||||||
|
|
||||||
await writeBackupToDexie(targetId, prepared, logbookKey)
|
await writeBackupToDexie(
|
||||||
|
targetId,
|
||||||
|
finalMeta,
|
||||||
|
logbookKey,
|
||||||
|
prepared.manifest,
|
||||||
|
prepared.files
|
||||||
|
)
|
||||||
await queueRestoredLogbookForSync(
|
await queueRestoredLogbookForSync(
|
||||||
targetId,
|
targetId,
|
||||||
prepared.logbook.encryptedTitle,
|
finalMeta.encryptedTitle,
|
||||||
logbookKey,
|
logbookKey,
|
||||||
prepared.payloads
|
prepared.manifest,
|
||||||
|
prepared.files
|
||||||
)
|
)
|
||||||
|
|
||||||
if (navigator.onLine) {
|
if (navigator.onLine) {
|
||||||
@@ -599,3 +637,14 @@ export function downloadBackupBlob(blob: Blob, filename: string): void {
|
|||||||
anchor.click()
|
anchor.click()
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Human-readable size for UI warnings. */
|
||||||
|
export function formatBackupBytes(bytes: number): string {
|
||||||
|
const fmt = (n: number) => formatAppDecimal(n, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
if (bytes < 1024 * 1024) return `${fmt(bytes / 1024)} KB`
|
||||||
|
return `${fmt(bytes / (1024 * 1024))} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BACKUP_SIZE_WARN_BYTES = 50_000_000
|
||||||
|
export const BACKUP_SIZE_CONFIRM_BYTES = 150_000_000
|
||||||
|
|||||||
@@ -0,0 +1,355 @@
|
|||||||
|
import { db } from '../db.js'
|
||||||
|
import { encBytesFromDexieFields, type DexieEncFields } from './encBlob.js'
|
||||||
|
import { buildZipArchive, utf8Bytes } from './zipArchive.js'
|
||||||
|
import {
|
||||||
|
BACKUP_FORMAT,
|
||||||
|
BACKUP_VERSION,
|
||||||
|
type BackupIndexedEntryFile,
|
||||||
|
type BackupIndexedPayloadFile,
|
||||||
|
type BackupIndexedTrackFile,
|
||||||
|
type BackupManifestCounts,
|
||||||
|
type BackupManifestFiles,
|
||||||
|
type BackupManifestV2,
|
||||||
|
type LogbookMetaJson
|
||||||
|
} from './manifest.js'
|
||||||
|
|
||||||
|
export interface CollectedBackupData {
|
||||||
|
logbookMeta: LogbookMetaJson
|
||||||
|
yacht: DexieEncFields | null
|
||||||
|
deviation: DexieEncFields | null
|
||||||
|
logbookCrewSelection: DexieEncFields | null
|
||||||
|
logbookVesselSelection: DexieEncFields | null
|
||||||
|
crews: Array<DexieEncFields & { payloadId: string; updatedAt: string }>
|
||||||
|
entries: Array<DexieEncFields & { payloadId: string; updatedAt: string }>
|
||||||
|
photos: Array<DexieEncFields & { payloadId: string; entryId: string; updatedAt: string }>
|
||||||
|
voiceMemos: Array<DexieEncFields & { payloadId: string; entryId: string; updatedAt: string }>
|
||||||
|
gpsTracks: Array<DexieEncFields & { entryId: string; updatedAt: string }>
|
||||||
|
nmeaArchives: Array<DexieEncFields & { entryId: string; updatedAt: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickEnc(row: {
|
||||||
|
encryptedData: string
|
||||||
|
iv: string
|
||||||
|
tag: string
|
||||||
|
}): DexieEncFields {
|
||||||
|
return {
|
||||||
|
encryptedData: row.encryptedData,
|
||||||
|
iv: row.iv,
|
||||||
|
tag: row.tag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function collectLogbookBackupData(
|
||||||
|
logbookId: string
|
||||||
|
): Promise<CollectedBackupData> {
|
||||||
|
const [
|
||||||
|
logbook,
|
||||||
|
yacht,
|
||||||
|
deviation,
|
||||||
|
logbookCrewSelection,
|
||||||
|
logbookVesselSelection,
|
||||||
|
crews,
|
||||||
|
entries,
|
||||||
|
photos,
|
||||||
|
voiceMemos,
|
||||||
|
gpsTracks,
|
||||||
|
nmeaArchives
|
||||||
|
] = await Promise.all([
|
||||||
|
db.logbooks.get(logbookId),
|
||||||
|
db.yachts.get(logbookId),
|
||||||
|
db.deviations.get(logbookId),
|
||||||
|
db.logbookCrewSelections.get(logbookId),
|
||||||
|
db.logbookVesselSelections.get(logbookId),
|
||||||
|
db.crews.where({ logbookId }).toArray(),
|
||||||
|
db.entries.where({ logbookId }).toArray(),
|
||||||
|
db.photos.where({ logbookId }).toArray(),
|
||||||
|
db.voiceMemos.where({ logbookId }).toArray(),
|
||||||
|
db.gpsTracks.where({ logbookId }).toArray(),
|
||||||
|
db.nmeaArchives.where({ logbookId }).toArray()
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!logbook) throw new Error('BACKUP_LOGBOOK_NOT_FOUND')
|
||||||
|
|
||||||
|
return {
|
||||||
|
logbookMeta: {
|
||||||
|
id: logbook.id,
|
||||||
|
encryptedTitle: logbook.encryptedTitle,
|
||||||
|
updatedAt: logbook.updatedAt,
|
||||||
|
isDemo: logbook.isDemo === 1
|
||||||
|
},
|
||||||
|
yacht: yacht ? pickEnc(yacht) : null,
|
||||||
|
deviation: deviation ? pickEnc(deviation) : null,
|
||||||
|
logbookCrewSelection: logbookCrewSelection ? pickEnc(logbookCrewSelection) : null,
|
||||||
|
logbookVesselSelection: logbookVesselSelection ? pickEnc(logbookVesselSelection) : null,
|
||||||
|
crews: crews.map((c) => ({ ...pickEnc(c), payloadId: c.payloadId, updatedAt: c.updatedAt })),
|
||||||
|
entries: entries.map((e) => ({
|
||||||
|
...pickEnc(e),
|
||||||
|
payloadId: e.payloadId,
|
||||||
|
updatedAt: e.updatedAt
|
||||||
|
})),
|
||||||
|
photos: photos.map((p) => ({
|
||||||
|
...pickEnc(p),
|
||||||
|
payloadId: p.payloadId,
|
||||||
|
entryId: p.entryId,
|
||||||
|
updatedAt: p.updatedAt
|
||||||
|
})),
|
||||||
|
voiceMemos: voiceMemos.map((v) => ({
|
||||||
|
...pickEnc(v),
|
||||||
|
payloadId: v.payloadId,
|
||||||
|
entryId: v.entryId,
|
||||||
|
updatedAt: v.updatedAt
|
||||||
|
})),
|
||||||
|
gpsTracks: gpsTracks.map((t) => ({
|
||||||
|
...pickEnc(t),
|
||||||
|
entryId: t.entryId,
|
||||||
|
updatedAt: t.updatedAt
|
||||||
|
})),
|
||||||
|
nmeaArchives: nmeaArchives.map((n) => ({
|
||||||
|
...pickEnc(n),
|
||||||
|
entryId: n.entryId,
|
||||||
|
updatedAt: n.updatedAt
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BackupProgressPhase = 'collect' | 'pack' | 'done'
|
||||||
|
|
||||||
|
export interface BackupExportProgress {
|
||||||
|
phase: BackupProgressPhase
|
||||||
|
current: number
|
||||||
|
total: number
|
||||||
|
bytesPacked: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuiltArchive {
|
||||||
|
zipBytes: Uint8Array
|
||||||
|
manifest: BackupManifestV2
|
||||||
|
counts: BackupManifestCounts
|
||||||
|
totalUncompressedBytes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function addEncFile(
|
||||||
|
zipFiles: Record<string, Uint8Array>,
|
||||||
|
path: string,
|
||||||
|
fields: DexieEncFields
|
||||||
|
): number {
|
||||||
|
const bytes = encBytesFromDexieFields(fields)
|
||||||
|
zipFiles[path] = bytes
|
||||||
|
return bytes.byteLength
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildArchiveFromCollected(
|
||||||
|
collected: CollectedBackupData,
|
||||||
|
keyEnc: Uint8Array,
|
||||||
|
options: {
|
||||||
|
exportedAt: string
|
||||||
|
appVersion?: string
|
||||||
|
onProgress?: (progress: BackupExportProgress) => void
|
||||||
|
}
|
||||||
|
): BuiltArchive {
|
||||||
|
const zipFiles: Record<string, Uint8Array> = {}
|
||||||
|
let totalUncompressedBytes = 0
|
||||||
|
|
||||||
|
const logbookPath = 'logbook.meta.json'
|
||||||
|
zipFiles[logbookPath] = utf8Bytes(JSON.stringify(collected.logbookMeta))
|
||||||
|
totalUncompressedBytes += zipFiles[logbookPath].byteLength
|
||||||
|
|
||||||
|
zipFiles['key.enc'] = keyEnc
|
||||||
|
totalUncompressedBytes += keyEnc.byteLength
|
||||||
|
|
||||||
|
const files: BackupManifestFiles = {
|
||||||
|
key: 'key.enc',
|
||||||
|
logbook: logbookPath,
|
||||||
|
yacht: null,
|
||||||
|
deviation: null,
|
||||||
|
logbookCrewSelection: null,
|
||||||
|
logbookVesselSelection: null,
|
||||||
|
crews: [],
|
||||||
|
entries: [],
|
||||||
|
photos: [],
|
||||||
|
voiceMemos: [],
|
||||||
|
gpsTracks: [],
|
||||||
|
nmeaArchives: []
|
||||||
|
}
|
||||||
|
|
||||||
|
const packSteps: Array<() => void> = []
|
||||||
|
|
||||||
|
if (collected.yacht) {
|
||||||
|
packSteps.push(() => {
|
||||||
|
const path = 'payloads/yacht.enc'
|
||||||
|
const size = addEncFile(zipFiles, path, collected.yacht!)
|
||||||
|
files.yacht = path
|
||||||
|
totalUncompressedBytes += size
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collected.deviation) {
|
||||||
|
packSteps.push(() => {
|
||||||
|
const path = 'payloads/deviation.enc'
|
||||||
|
const size = addEncFile(zipFiles, path, collected.deviation!)
|
||||||
|
files.deviation = path
|
||||||
|
totalUncompressedBytes += size
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collected.logbookCrewSelection) {
|
||||||
|
packSteps.push(() => {
|
||||||
|
const path = 'payloads/logbook-crew.enc'
|
||||||
|
const size = addEncFile(zipFiles, path, collected.logbookCrewSelection!)
|
||||||
|
files.logbookCrewSelection = path
|
||||||
|
totalUncompressedBytes += size
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collected.logbookVesselSelection) {
|
||||||
|
packSteps.push(() => {
|
||||||
|
const path = 'payloads/logbook-vessel.enc'
|
||||||
|
const size = addEncFile(zipFiles, path, collected.logbookVesselSelection!)
|
||||||
|
files.logbookVesselSelection = path
|
||||||
|
totalUncompressedBytes += size
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const c of collected.crews) {
|
||||||
|
packSteps.push(() => {
|
||||||
|
const path = `payloads/crews/${c.payloadId}.enc`
|
||||||
|
const size = addEncFile(zipFiles, path, c)
|
||||||
|
const index: BackupIndexedPayloadFile = {
|
||||||
|
path,
|
||||||
|
payloadId: c.payloadId,
|
||||||
|
updatedAt: c.updatedAt,
|
||||||
|
bytes: size
|
||||||
|
}
|
||||||
|
files.crews.push(index)
|
||||||
|
totalUncompressedBytes += size
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const e of collected.entries) {
|
||||||
|
packSteps.push(() => {
|
||||||
|
const path = `payloads/entries/${e.payloadId}.enc`
|
||||||
|
const size = addEncFile(zipFiles, path, e)
|
||||||
|
const index: BackupIndexedPayloadFile = {
|
||||||
|
path,
|
||||||
|
payloadId: e.payloadId,
|
||||||
|
updatedAt: e.updatedAt,
|
||||||
|
bytes: size
|
||||||
|
}
|
||||||
|
files.entries.push(index)
|
||||||
|
totalUncompressedBytes += size
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const p of collected.photos) {
|
||||||
|
packSteps.push(() => {
|
||||||
|
const path = `payloads/photos/${p.payloadId}.enc`
|
||||||
|
const size = addEncFile(zipFiles, path, p)
|
||||||
|
const index: BackupIndexedEntryFile = {
|
||||||
|
path,
|
||||||
|
payloadId: p.payloadId,
|
||||||
|
entryId: p.entryId,
|
||||||
|
updatedAt: p.updatedAt,
|
||||||
|
bytes: size
|
||||||
|
}
|
||||||
|
files.photos.push(index)
|
||||||
|
totalUncompressedBytes += size
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const v of collected.voiceMemos) {
|
||||||
|
packSteps.push(() => {
|
||||||
|
const path = `payloads/voice-memos/${v.payloadId}.enc`
|
||||||
|
const size = addEncFile(zipFiles, path, v)
|
||||||
|
const index: BackupIndexedEntryFile = {
|
||||||
|
path,
|
||||||
|
payloadId: v.payloadId,
|
||||||
|
entryId: v.entryId,
|
||||||
|
updatedAt: v.updatedAt,
|
||||||
|
bytes: size
|
||||||
|
}
|
||||||
|
files.voiceMemos.push(index)
|
||||||
|
totalUncompressedBytes += size
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const t of collected.gpsTracks) {
|
||||||
|
packSteps.push(() => {
|
||||||
|
const path = `payloads/gps-tracks/${t.entryId}.enc`
|
||||||
|
const size = addEncFile(zipFiles, path, t)
|
||||||
|
const index: BackupIndexedTrackFile = {
|
||||||
|
path,
|
||||||
|
entryId: t.entryId,
|
||||||
|
updatedAt: t.updatedAt,
|
||||||
|
bytes: size
|
||||||
|
}
|
||||||
|
files.gpsTracks.push(index)
|
||||||
|
totalUncompressedBytes += size
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const n of collected.nmeaArchives) {
|
||||||
|
packSteps.push(() => {
|
||||||
|
const path = `payloads/nmea-archives/${n.entryId}.enc`
|
||||||
|
const size = addEncFile(zipFiles, path, n)
|
||||||
|
const index: BackupIndexedTrackFile = {
|
||||||
|
path,
|
||||||
|
entryId: n.entryId,
|
||||||
|
updatedAt: n.updatedAt,
|
||||||
|
bytes: size
|
||||||
|
}
|
||||||
|
files.nmeaArchives.push(index)
|
||||||
|
totalUncompressedBytes += size
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = packSteps.length
|
||||||
|
packSteps.forEach((step, i) => {
|
||||||
|
step()
|
||||||
|
options.onProgress?.({
|
||||||
|
phase: 'pack',
|
||||||
|
current: i + 1,
|
||||||
|
total,
|
||||||
|
bytesPacked: totalUncompressedBytes
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const counts: BackupManifestCounts = {
|
||||||
|
entries: collected.entries.length,
|
||||||
|
photos: collected.photos.length,
|
||||||
|
voiceMemos: collected.voiceMemos.length,
|
||||||
|
crews: collected.crews.length,
|
||||||
|
gpsTracks: collected.gpsTracks.length,
|
||||||
|
nmeaArchives: collected.nmeaArchives.length,
|
||||||
|
hasYacht: !!collected.yacht,
|
||||||
|
hasDeviation: !!collected.deviation,
|
||||||
|
hasLogbookCrewSelection: !!collected.logbookCrewSelection,
|
||||||
|
hasLogbookVesselSelection: !!collected.logbookVesselSelection
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifest: BackupManifestV2 = {
|
||||||
|
format: BACKUP_FORMAT,
|
||||||
|
version: BACKUP_VERSION,
|
||||||
|
exportedAt: options.exportedAt,
|
||||||
|
appVersion: options.appVersion,
|
||||||
|
compression: 'zip-deflate-6',
|
||||||
|
logbookId: collected.logbookMeta.id,
|
||||||
|
counts,
|
||||||
|
totalUncompressedBytes,
|
||||||
|
files
|
||||||
|
}
|
||||||
|
|
||||||
|
zipFiles['manifest.json'] = utf8Bytes(JSON.stringify(manifest))
|
||||||
|
totalUncompressedBytes += zipFiles['manifest.json'].byteLength
|
||||||
|
|
||||||
|
const zipBytes = buildZipArchive(zipFiles)
|
||||||
|
manifest.totalUncompressedBytes = totalUncompressedBytes
|
||||||
|
|
||||||
|
options.onProgress?.({
|
||||||
|
phase: 'done',
|
||||||
|
current: total,
|
||||||
|
total,
|
||||||
|
bytesPacked: totalUncompressedBytes
|
||||||
|
})
|
||||||
|
|
||||||
|
return { zipBytes, manifest, counts, totalUncompressedBytes }
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
dexieFieldsFromEncBytes,
|
||||||
|
encBytesFromDexieFields,
|
||||||
|
ENC_HEADER_SIZE
|
||||||
|
} from './encBlob.js'
|
||||||
|
|
||||||
|
function toB64(bytes: number[]): string {
|
||||||
|
return btoa(String.fromCharCode(...bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('encBlob', () => {
|
||||||
|
it('round-trips dexie AES-GCM fields', () => {
|
||||||
|
const fields = {
|
||||||
|
encryptedData: toB64([9, 8, 7]),
|
||||||
|
iv: toB64(Array.from({ length: 12 }, (_, i) => i)),
|
||||||
|
tag: toB64(Array.from({ length: 16 }, (_, i) => i + 20))
|
||||||
|
}
|
||||||
|
const enc = encBytesFromDexieFields(fields)
|
||||||
|
expect(enc.byteLength).toBe(ENC_HEADER_SIZE + 3)
|
||||||
|
expect(dexieFieldsFromEncBytes(enc)).toEqual(fields)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects invalid magic', () => {
|
||||||
|
expect(() => dexieFieldsFromEncBytes(new Uint8Array(40))).toThrow('BACKUP_INVALID_ENC')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { base64ToBuffer, bufferToBase64 } from '../crypto.js'
|
||||||
|
|
||||||
|
export const ENC_MAGIC = new Uint8Array([0x4b, 0x44, 0x41, 0x42]) // KDAB
|
||||||
|
export const ENC_FORMAT_VERSION = 1
|
||||||
|
export const ENC_HEADER_SIZE = 33 // 4 + 1 + 12 + 16
|
||||||
|
|
||||||
|
export interface DexieEncFields {
|
||||||
|
encryptedData: string
|
||||||
|
iv: string
|
||||||
|
tag: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encBytesFromDexieFields(fields: DexieEncFields): Uint8Array {
|
||||||
|
const iv = new Uint8Array(base64ToBuffer(fields.iv))
|
||||||
|
const tag = new Uint8Array(base64ToBuffer(fields.tag))
|
||||||
|
const ciphertext = new Uint8Array(base64ToBuffer(fields.encryptedData))
|
||||||
|
if (iv.length !== 12) throw new Error('BACKUP_INVALID_ENC')
|
||||||
|
if (tag.length !== 16) throw new Error('BACKUP_INVALID_ENC')
|
||||||
|
|
||||||
|
const out = new Uint8Array(ENC_HEADER_SIZE + ciphertext.length)
|
||||||
|
out.set(ENC_MAGIC, 0)
|
||||||
|
out[4] = ENC_FORMAT_VERSION
|
||||||
|
out.set(iv, 5)
|
||||||
|
out.set(tag, 17)
|
||||||
|
out.set(ciphertext, 33)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dexieFieldsFromEncBytes(bytes: Uint8Array): DexieEncFields {
|
||||||
|
if (bytes.length < ENC_HEADER_SIZE) throw new Error('BACKUP_INVALID_ENC')
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
if (bytes[i] !== ENC_MAGIC[i]) throw new Error('BACKUP_INVALID_ENC')
|
||||||
|
}
|
||||||
|
if (bytes[4] !== ENC_FORMAT_VERSION) throw new Error('BACKUP_INVALID_ENC')
|
||||||
|
|
||||||
|
const iv = bufferToBase64(bytes.slice(5, 17).buffer)
|
||||||
|
const tag = bufferToBase64(bytes.slice(17, 33).buffer)
|
||||||
|
const ciphertext = bufferToBase64(bytes.slice(33).buffer)
|
||||||
|
return { encryptedData: ciphertext, iv, tag }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encByteLength(fields: DexieEncFields): number {
|
||||||
|
const ct = base64ToBuffer(fields.encryptedData).byteLength
|
||||||
|
return ENC_HEADER_SIZE + ct
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
export const BACKUP_FORMAT = 'kapteins-daagbok-backup' as const
|
||||||
|
export const BACKUP_VERSION = 2 as const
|
||||||
|
|
||||||
|
export interface BackupIndexedFile {
|
||||||
|
path: string
|
||||||
|
updatedAt: string
|
||||||
|
bytes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupIndexedPayloadFile extends BackupIndexedFile {
|
||||||
|
payloadId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupIndexedEntryFile extends BackupIndexedPayloadFile {
|
||||||
|
entryId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupIndexedTrackFile extends BackupIndexedFile {
|
||||||
|
entryId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupManifestCounts {
|
||||||
|
entries: number
|
||||||
|
photos: number
|
||||||
|
voiceMemos: number
|
||||||
|
crews: number
|
||||||
|
gpsTracks: number
|
||||||
|
nmeaArchives: number
|
||||||
|
hasYacht: boolean
|
||||||
|
hasDeviation: boolean
|
||||||
|
hasLogbookCrewSelection: boolean
|
||||||
|
hasLogbookVesselSelection: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupManifestFiles {
|
||||||
|
key: string
|
||||||
|
logbook: string
|
||||||
|
yacht: string | null
|
||||||
|
deviation: string | null
|
||||||
|
logbookCrewSelection: string | null
|
||||||
|
logbookVesselSelection: string | null
|
||||||
|
crews: BackupIndexedPayloadFile[]
|
||||||
|
entries: BackupIndexedPayloadFile[]
|
||||||
|
photos: BackupIndexedEntryFile[]
|
||||||
|
voiceMemos: BackupIndexedEntryFile[]
|
||||||
|
gpsTracks: BackupIndexedTrackFile[]
|
||||||
|
nmeaArchives: BackupIndexedTrackFile[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupManifestV2 {
|
||||||
|
format: typeof BACKUP_FORMAT
|
||||||
|
version: typeof BACKUP_VERSION
|
||||||
|
exportedAt: string
|
||||||
|
appVersion?: string
|
||||||
|
compression: 'zip-deflate-6'
|
||||||
|
logbookId: string
|
||||||
|
counts: BackupManifestCounts
|
||||||
|
totalUncompressedBytes: number
|
||||||
|
files: BackupManifestFiles
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogbookMetaJson {
|
||||||
|
id: string
|
||||||
|
encryptedTitle: string
|
||||||
|
updatedAt: string
|
||||||
|
isDemo?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseManifestJson(text: string): BackupManifestV2 {
|
||||||
|
let parsed: unknown
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(text)
|
||||||
|
} catch {
|
||||||
|
throw new Error('BACKUP_INVALID_FORMAT')
|
||||||
|
}
|
||||||
|
if (!isBackupManifestV2(parsed)) {
|
||||||
|
throw new Error('BACKUP_INVALID_FORMAT')
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBackupManifestV2(value: unknown): value is BackupManifestV2 {
|
||||||
|
if (!value || typeof value !== 'object') return false
|
||||||
|
const obj = value as Partial<BackupManifestV2>
|
||||||
|
return (
|
||||||
|
obj.format === BACKUP_FORMAT &&
|
||||||
|
obj.version === BACKUP_VERSION &&
|
||||||
|
typeof obj.exportedAt === 'string' &&
|
||||||
|
typeof obj.logbookId === 'string' &&
|
||||||
|
!!obj.counts &&
|
||||||
|
!!obj.files
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeManifest(manifest: BackupManifestV2): string {
|
||||||
|
return JSON.stringify(manifest)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { strToU8, unzipSync, zipSync } from 'fflate'
|
||||||
|
import { parseManifestJson, type BackupManifestV2 } from './manifest.js'
|
||||||
|
|
||||||
|
const ZIP_LEVEL = 6
|
||||||
|
|
||||||
|
export function buildZipArchive(files: Record<string, Uint8Array>): Uint8Array {
|
||||||
|
return zipSync(files, { level: ZIP_LEVEL })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unzipArchive(data: Uint8Array): Record<string, Uint8Array> {
|
||||||
|
try {
|
||||||
|
return unzipSync(data)
|
||||||
|
} catch {
|
||||||
|
throw new Error('BACKUP_INVALID_ARCHIVE')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readManifestFromArchive(
|
||||||
|
files: Record<string, Uint8Array>
|
||||||
|
): BackupManifestV2 {
|
||||||
|
const raw = files['manifest.json']
|
||||||
|
if (!raw) throw new Error('BACKUP_INVALID_FORMAT')
|
||||||
|
const text = new TextDecoder().decode(raw)
|
||||||
|
return parseManifestJson(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readTextFile(files: Record<string, Uint8Array>, path: string): string {
|
||||||
|
const raw = files[path]
|
||||||
|
if (!raw) throw new Error('BACKUP_MISSING_BLOB')
|
||||||
|
return new TextDecoder().decode(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readBinaryFile(files: Record<string, Uint8Array>, path: string): Uint8Array {
|
||||||
|
const raw = files[path]
|
||||||
|
if (!raw) throw new Error('BACKUP_MISSING_BLOB')
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
export function utf8Bytes(text: string): Uint8Array {
|
||||||
|
return strToU8(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isZipArchive(bytes: Uint8Array): boolean {
|
||||||
|
return bytes.length >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4b
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -91,6 +91,7 @@ export function clearLogbookKeysCache() {
|
|||||||
export async function ensureLogbookKey(logbookId: string): Promise<ArrayBuffer> {
|
export async function ensureLogbookKey(logbookId: string): Promise<ArrayBuffer> {
|
||||||
const localLb = await db.logbooks.get(logbookId)
|
const localLb = await db.logbooks.get(logbookId)
|
||||||
const encryptedTitle = localLb ? localLb.encryptedTitle : ''
|
const encryptedTitle = localLb ? localLb.encryptedTitle : ''
|
||||||
|
const isShared = localLb?.isShared === 1
|
||||||
const masterKey = getActiveMasterKey()
|
const masterKey = getActiveMasterKey()
|
||||||
|
|
||||||
let key = await getLogbookKey(logbookId)
|
let key = await getLogbookKey(logbookId)
|
||||||
@@ -103,6 +104,11 @@ export async function ensureLogbookKey(logbookId: string): Promise<ArrayBuffer>
|
|||||||
// Key works, return it
|
// Key works, return it
|
||||||
return key
|
return key
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (isShared) {
|
||||||
|
throw new Error(
|
||||||
|
'Shared logbook encryption key is missing or invalid. Please go online and refresh your logbooks.'
|
||||||
|
)
|
||||||
|
}
|
||||||
console.warn('Stored logbook key failed to decrypt title. Testing if master key works (legacy migration)...')
|
console.warn('Stored logbook key failed to decrypt title. Testing if master key works (legacy migration)...')
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(encryptedTitle)
|
const parsed = JSON.parse(encryptedTitle)
|
||||||
@@ -145,6 +151,12 @@ export async function ensureLogbookKey(logbookId: string): Promise<ArrayBuffer>
|
|||||||
|
|
||||||
// If no logbook key exists yet
|
// If no logbook key exists yet
|
||||||
if (!key) {
|
if (!key) {
|
||||||
|
if (isShared) {
|
||||||
|
throw new Error(
|
||||||
|
'Shared logbook encryption key not found. Please go online and refresh your logbooks.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (encryptedTitle && masterKey) {
|
if (encryptedTitle && masterKey) {
|
||||||
try {
|
try {
|
||||||
// Check if title is already decryptable using masterKey (meaning it is a legacy logbook)
|
// Check if title is already decryptable using masterKey (meaning it is a legacy logbook)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
|
import { formatAppDecimal } from '../../utils/numberFormat.js'
|
||||||
import type { NmeaChangeEvent, NmeaDetectionConfig, NmeaTimePoint } from './nmeaTypes.js'
|
import type { NmeaChangeEvent, NmeaDetectionConfig, NmeaTimePoint } from './nmeaTypes.js'
|
||||||
import { DEFAULT_NMEA_DETECTION_CONFIG } from './nmeaTypes.js'
|
import { DEFAULT_NMEA_DETECTION_CONFIG } from './nmeaTypes.js'
|
||||||
import { angularDelta } from './nmeaTimeSeries.js'
|
import { angularDelta } from './nmeaTimeSeries.js'
|
||||||
|
|
||||||
|
function formatNmeaDecimal(value: number): string {
|
||||||
|
return formatAppDecimal(value, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
||||||
|
}
|
||||||
|
|
||||||
function pushUnique(events: NmeaChangeEvent[], event: NmeaChangeEvent, minGapMs: number) {
|
function pushUnique(events: NmeaChangeEvent[], event: NmeaChangeEvent, minGapMs: number) {
|
||||||
const last = events[events.length - 1]
|
const last = events[events.length - 1]
|
||||||
if (last && last.type === event.type && event.timestamp - last.timestamp < minGapMs) return
|
if (last && last.type === event.type && event.timestamp - last.timestamp < minGapMs) return
|
||||||
@@ -64,7 +69,7 @@ export function detectNmeaChanges(
|
|||||||
timestamp: p.timestamp,
|
timestamp: p.timestamp,
|
||||||
confidence: 'medium',
|
confidence: 'medium',
|
||||||
summaryKey: 'logs.nmea_change_wind_speed',
|
summaryKey: 'logs.nmea_change_wind_speed',
|
||||||
summaryParams: { from: lastWindSpeed.toFixed(1), to: p.windSpeedKnots.toFixed(1) },
|
summaryParams: { from: formatNmeaDecimal(lastWindSpeed), to: formatNmeaDecimal(p.windSpeedKnots) },
|
||||||
data: p
|
data: p
|
||||||
}, config.dedupeWindowMs)
|
}, config.dedupeWindowMs)
|
||||||
}
|
}
|
||||||
@@ -79,7 +84,7 @@ export function detectNmeaChanges(
|
|||||||
timestamp: p.timestamp,
|
timestamp: p.timestamp,
|
||||||
confidence: 'medium',
|
confidence: 'medium',
|
||||||
summaryKey: 'logs.nmea_change_pressure',
|
summaryKey: 'logs.nmea_change_pressure',
|
||||||
summaryParams: { from: lastPressure.toFixed(1), to: p.pressureHpa.toFixed(1) },
|
summaryParams: { from: formatNmeaDecimal(lastPressure), to: formatNmeaDecimal(p.pressureHpa) },
|
||||||
data: p
|
data: p
|
||||||
}, config.dedupeWindowMs)
|
}, config.dedupeWindowMs)
|
||||||
}
|
}
|
||||||
@@ -95,7 +100,7 @@ export function detectNmeaChanges(
|
|||||||
timestamp: p.timestamp,
|
timestamp: p.timestamp,
|
||||||
confidence: 'high',
|
confidence: 'high',
|
||||||
summaryKey: 'logs.nmea_change_depth',
|
summaryKey: 'logs.nmea_change_depth',
|
||||||
summaryParams: { from: lastDepth.toFixed(1), to: p.depthM.toFixed(1) },
|
summaryParams: { from: formatNmeaDecimal(lastDepth), to: formatNmeaDecimal(p.depthM) },
|
||||||
data: p
|
data: p
|
||||||
}, config.dedupeWindowMs)
|
}, config.dedupeWindowMs)
|
||||||
}
|
}
|
||||||
@@ -156,7 +161,7 @@ export function detectNmeaChanges(
|
|||||||
timestamp: p.timestamp,
|
timestamp: p.timestamp,
|
||||||
confidence: 'medium',
|
confidence: 'medium',
|
||||||
summaryKey: 'logs.nmea_change_water_temp',
|
summaryKey: 'logs.nmea_change_water_temp',
|
||||||
summaryParams: { from: lastWaterTemp.toFixed(1), to: p.waterTempC.toFixed(1) },
|
summaryParams: { from: formatNmeaDecimal(lastWaterTemp), to: formatNmeaDecimal(p.waterTempC) },
|
||||||
data: p
|
data: p
|
||||||
}, config.dedupeWindowMs)
|
}, config.dedupeWindowMs)
|
||||||
}
|
}
|
||||||
@@ -200,7 +205,7 @@ export function detectNmeaChanges(
|
|||||||
timestamp: p.timestamp,
|
timestamp: p.timestamp,
|
||||||
confidence: 'low',
|
confidence: 'low',
|
||||||
summaryKey: 'logs.nmea_change_speed',
|
summaryKey: 'logs.nmea_change_speed',
|
||||||
summaryParams: { from: lastSog.toFixed(1), to: sog.toFixed(1) },
|
summaryParams: { from: formatNmeaDecimal(lastSog), to: formatNmeaDecimal(sog) },
|
||||||
data: p
|
data: p
|
||||||
}, config.dedupeWindowMs)
|
}, config.dedupeWindowMs)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { TFunction } from 'i18next'
|
|||||||
import type { LogEventPayload } from '../../utils/logEntryPayload.js'
|
import type { LogEventPayload } from '../../utils/logEntryPayload.js'
|
||||||
import { normalizeLogEvent } from '../../utils/logEntryPayload.js'
|
import { normalizeLogEvent } from '../../utils/logEntryPayload.js'
|
||||||
import { formatCourseAngle } from '../../utils/courseAngle.js'
|
import { formatCourseAngle } from '../../utils/courseAngle.js'
|
||||||
|
import { formatAppDecimal, formatCanonicalCoordinate } from '../../utils/numberFormat.js'
|
||||||
import { degreesToCardinal } from '../../utils/courseAngle.js'
|
import { degreesToCardinal } from '../../utils/courseAngle.js'
|
||||||
import type {
|
import type {
|
||||||
NmeaChangeEvent,
|
NmeaChangeEvent,
|
||||||
@@ -33,9 +34,12 @@ function pointToLogEvent(
|
|||||||
windDirection: windDir,
|
windDirection: windDir,
|
||||||
windStrength: point.windSpeedKnots != null ? String(point.windSpeedKnots) : '',
|
windStrength: point.windSpeedKnots != null ? String(point.windSpeedKnots) : '',
|
||||||
windPressure: point.pressureHpa != null ? String(Math.round(point.pressureHpa)) : '',
|
windPressure: point.pressureHpa != null ? String(Math.round(point.pressureHpa)) : '',
|
||||||
gpsLat: point.lat != null ? point.lat.toFixed(6) : '',
|
gpsLat: point.lat != null ? formatCanonicalCoordinate(point.lat) : '',
|
||||||
gpsLng: point.lng != null ? point.lng.toFixed(6) : '',
|
gpsLng: point.lng != null ? formatCanonicalCoordinate(point.lng) : '',
|
||||||
logReading: point.logDistanceNm != null ? point.logDistanceNm.toFixed(2) : '',
|
logReading:
|
||||||
|
point.logDistanceNm != null
|
||||||
|
? formatAppDecimal(point.logDistanceNm, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||||
|
: '',
|
||||||
sailsOrMotor,
|
sailsOrMotor,
|
||||||
remarks
|
remarks
|
||||||
})
|
})
|
||||||
@@ -51,7 +55,11 @@ function buildRemarks(change: NmeaChangeEvent, t: TFunction): string {
|
|||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
parts.push(t(change.summaryKey, change.summaryParams ?? {}))
|
parts.push(t(change.summaryKey, change.summaryParams ?? {}))
|
||||||
if (change.data?.depthM != null) {
|
if (change.data?.depthM != null) {
|
||||||
parts.push(t('logs.nmea_remark_depth', { depth: change.data.depthM.toFixed(1) }))
|
parts.push(
|
||||||
|
t('logs.nmea_remark_depth', {
|
||||||
|
depth: formatAppDecimal(change.data.depthM, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
||||||
|
})
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (change.confidence === 'low') {
|
if (change.confidence === 'low') {
|
||||||
parts.push(t('logs.nmea_remark_uncertain'))
|
parts.push(t('logs.nmea_remark_uncertain'))
|
||||||
|
|||||||
@@ -13,12 +13,13 @@ function formatPasskeySignDate(signedAt: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function generateLogbookPagePdf(logbookId: string, entryId: string, preloadedData?: { yacht: any; entry: any }): Promise<jsPDF> {
|
export async function generateLogbookPagePdf(logbookId: string, entryId: string, preloadedData?: { yacht: any; entry: any }): Promise<jsPDF> {
|
||||||
let yachtName = '', homePort = '', registration = '', callsign = '', atis = '', mmsi = '';
|
let yachtName = '', owner = '', homePort = '', registration = '', callsign = '', atis = '', mmsi = '';
|
||||||
let entry: any = null;
|
let entry: any = null;
|
||||||
|
|
||||||
if (preloadedData) {
|
if (preloadedData) {
|
||||||
const yacht = preloadedData.yacht || {};
|
const yacht = preloadedData.yacht || {};
|
||||||
yachtName = yacht.name || '';
|
yachtName = yacht.name || '';
|
||||||
|
owner = yacht.owner || '';
|
||||||
homePort = yacht.port || '';
|
homePort = yacht.port || '';
|
||||||
registration = yacht.registrationNumber || yacht.registration || '';
|
registration = yacht.registrationNumber || yacht.registration || '';
|
||||||
callsign = yacht.callSign || '';
|
callsign = yacht.callSign || '';
|
||||||
@@ -31,20 +32,16 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
|
|||||||
throw new Error('Encryption key not found. Please log in.')
|
throw new Error('Encryption key not found. Please log in.')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Fetch Yacht details
|
const { resolveVesselForLogbook } = await import('./resolveVessel.js')
|
||||||
const yachtRecord = await db.yachts.get(logbookId);
|
const yacht = await resolveVesselForLogbook(logbookId)
|
||||||
if (yachtRecord) {
|
if (yacht) {
|
||||||
try {
|
yachtName = yacht.name || ''
|
||||||
const yacht = await decryptJson(yachtRecord.encryptedData, yachtRecord.iv, yachtRecord.tag, masterKey);
|
owner = yacht.owner || ''
|
||||||
yachtName = yacht.name || '';
|
homePort = yacht.homePort || ''
|
||||||
homePort = yacht.port || '';
|
registration = yacht.registrationNumber || ''
|
||||||
registration = yacht.registrationNumber || yacht.registration || '';
|
callsign = yacht.callSign || ''
|
||||||
callsign = yacht.callSign || '';
|
atis = yacht.atis || ''
|
||||||
atis = yacht.atis || '';
|
mmsi = yacht.mmsi || ''
|
||||||
mmsi = yacht.mmsi || '';
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to decrypt yacht details for PDF:', e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Fetch active Entry
|
// 2. Fetch active Entry
|
||||||
@@ -79,24 +76,56 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
|
|||||||
doc.setFontSize(8.5);
|
doc.setFontSize(8.5);
|
||||||
doc.setFont('Helvetica', 'normal');
|
doc.setFont('Helvetica', 'normal');
|
||||||
doc.text(`Yachtname: ${yachtName || '—'}`, 10, 21);
|
doc.text(`Yachtname: ${yachtName || '—'}`, 10, 21);
|
||||||
doc.text(`Heimathafen: ${homePort || '—'}`, 60, 21);
|
doc.text(`Eigner: ${owner || '—'}`, 55, 21);
|
||||||
doc.text(`Kennzeichen: ${registration || '—'}`, 110, 21);
|
doc.text(`Heimathafen: ${homePort || '—'}`, 100, 21);
|
||||||
doc.text(`Rufzeichen: ${callsign || '—'}`, 160, 21);
|
doc.text(`Kennzeichen: ${registration || '—'}`, 145, 21);
|
||||||
doc.text(`ATIS: ${atis || '—'}`, 210, 21);
|
doc.text(`Rufzeichen: ${callsign || '—'}`, 190, 21);
|
||||||
doc.text(`MMSI: ${mmsi || '—'}`, 250, 21);
|
doc.text(`ATIS: ${atis || '—'}`, 230, 21);
|
||||||
|
doc.text(`MMSI: ${mmsi || '—'}`, 260, 21);
|
||||||
|
|
||||||
doc.text(`Datum: ${entry.date || '—'}`, 10, 23);
|
doc.text(`Datum: ${entry.date || '—'}`, 10, 24);
|
||||||
doc.text(`Reisetag: ${entry.dayOfTravel || '—'}`, 60, 23);
|
doc.text(`Reisetag: ${entry.dayOfTravel || '—'}`, 60, 24);
|
||||||
doc.text(`Reise von (Departure): ${entry.departure || '—'}`, 110, 23);
|
doc.text(`Reise von (Departure): ${entry.departure || '—'}`, 110, 24);
|
||||||
doc.text(`nach (Destination): ${entry.destination || '—'}`, 200, 23);
|
doc.text(`nach (Destination): ${entry.destination || '—'}`, 200, 24);
|
||||||
|
|
||||||
|
// Format Crew names with initials
|
||||||
|
const crewSnapshots = (entry.crewSnapshotsById as Record<string, any>) || {}
|
||||||
|
const crewList: string[] = []
|
||||||
|
|
||||||
|
if (entry.selectedSkipperId && crewSnapshots[entry.selectedSkipperId]) {
|
||||||
|
const name = crewSnapshots[entry.selectedSkipperId].name || 'Skipper'
|
||||||
|
const initial = name.trim().split(/\s+/)[0]?.charAt(0).toUpperCase() || 'S'
|
||||||
|
crewList.push(`${name} [${initial}] (Skipper)`)
|
||||||
|
} else if (crewSnapshots['skipper']) {
|
||||||
|
const name = crewSnapshots['skipper'].name || 'Skipper'
|
||||||
|
crewList.push(`${name} [S] (Skipper)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(entry.selectedCrewIds)) {
|
||||||
|
for (const crewId of entry.selectedCrewIds) {
|
||||||
|
const snap = crewSnapshots[crewId]
|
||||||
|
if (snap) {
|
||||||
|
const name = snap.name || ''
|
||||||
|
const initial = name.trim().split(/\s+/)[0]?.charAt(0).toUpperCase() || '?'
|
||||||
|
crewList.push(`${name} [${initial}]`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const crewText = crewList.length > 0 ? `Besatzung (Crew): ${crewList.join(', ')}` : ''
|
||||||
|
|
||||||
|
doc.setFont('Helvetica', 'normal');
|
||||||
if (entry.trackDistanceNm) {
|
if (entry.trackDistanceNm) {
|
||||||
doc.setFont('Helvetica', 'normal');
|
|
||||||
doc.text(
|
doc.text(
|
||||||
`GPS-Track: ${entry.trackDistanceNm} sm · max. ${entry.trackSpeedMaxKn ?? '—'} kn · Ø ${entry.trackSpeedAvgKn ?? '—'} kn`,
|
`GPS-Track: ${entry.trackDistanceNm} sm · max. ${entry.trackSpeedMaxKn ?? '—'} kn · Ø ${entry.trackSpeedAvgKn ?? '—'} kn`,
|
||||||
10,
|
10,
|
||||||
27
|
27
|
||||||
);
|
);
|
||||||
|
if (crewText) {
|
||||||
|
doc.text(crewText, 140, 27);
|
||||||
|
}
|
||||||
|
} else if (crewText) {
|
||||||
|
doc.text(crewText, 10, 27);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Divider line
|
// Divider line
|
||||||
@@ -180,8 +209,28 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
|
|||||||
doc.text(gps, writeX + 1, y + 4.2);
|
doc.text(gps, writeX + 1, y + 4.2);
|
||||||
writeX += colWidths[11];
|
writeX += colWidths[11];
|
||||||
|
|
||||||
|
const crewSnapshots = (entry.crewSnapshotsById as Record<string, any>) || {};
|
||||||
|
let initial = '';
|
||||||
|
if (ev.creatorId) {
|
||||||
|
const snap = crewSnapshots[ev.creatorId];
|
||||||
|
let name = '';
|
||||||
|
if (snap) {
|
||||||
|
name = snap.name || '';
|
||||||
|
} else if (ev.creatorId === 'skipper') {
|
||||||
|
name = 'Skipper';
|
||||||
|
} else {
|
||||||
|
name = ev.creatorId;
|
||||||
|
}
|
||||||
|
if (name) {
|
||||||
|
initial = name.trim().split(/\s+/)[0]?.charAt(0).toUpperCase() || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Clip remarks to fit within the 94mm bounds
|
// Clip remarks to fit within the 94mm bounds
|
||||||
const remarks = ev.remarks || '';
|
let remarks = ev.remarks || '';
|
||||||
|
if (initial) {
|
||||||
|
remarks = `[${initial}] ${remarks}`;
|
||||||
|
}
|
||||||
const maxChars = 65;
|
const maxChars = 65;
|
||||||
const clippedRemarks = remarks.length > maxChars ? remarks.substring(0, maxChars) + '...' : remarks;
|
const clippedRemarks = remarks.length > maxChars ? remarks.substring(0, maxChars) + '...' : remarks;
|
||||||
doc.text(clippedRemarks, writeX + 1, y + 4.2);
|
doc.text(clippedRemarks, writeX + 1, y + 4.2);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,17 +27,62 @@ export function getNotificationPermission(): NotificationPermission | 'unsupport
|
|||||||
return Notification.permission
|
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> {
|
async function fetchVapidPublicKey(): Promise<string | null> {
|
||||||
|
if (cachedVapidKey) return cachedVapidKey
|
||||||
|
|
||||||
const envKey = import.meta.env.VITE_VAPID_PUBLIC_KEY
|
const envKey = import.meta.env.VITE_VAPID_PUBLIC_KEY
|
||||||
if (typeof envKey === 'string' && envKey.trim()) {
|
if (typeof envKey === 'string' && envKey.trim()) {
|
||||||
return envKey.trim()
|
cachedVapidKey = envKey.trim()
|
||||||
|
return cachedVapidKey
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/vapid-public-key`)
|
const res = await fetch(`${API_BASE}/vapid-public-key`)
|
||||||
if (!res.ok) return null
|
if (!res.ok) return null
|
||||||
const data = await res.json()
|
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 {
|
} catch {
|
||||||
return null
|
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> {
|
async function saveSubscriptionToServer(subscription: PushSubscription): Promise<void> {
|
||||||
if (!localStorage.getItem('active_userid')) throw new Error('Not authenticated')
|
if (!localStorage.getItem('active_userid')) throw new Error('Not authenticated')
|
||||||
|
|
||||||
|
const endpoint = subscription.endpoint
|
||||||
const json = subscription.toJSON()
|
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')
|
throw new Error('Invalid push subscription')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,8 +180,8 @@ async function saveSubscriptionToServer(subscription: PushSubscription): Promise
|
|||||||
await apiJson(`${API_BASE}/subscription`, {
|
await apiJson(`${API_BASE}/subscription`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
endpoint: json.endpoint,
|
endpoint,
|
||||||
keys: json.keys,
|
keys: { p256dh, auth },
|
||||||
locale,
|
locale,
|
||||||
userAgent: navigator.userAgent
|
userAgent: navigator.userAgent
|
||||||
})
|
})
|
||||||
@@ -98,35 +193,48 @@ export async function subscribeToPush(): Promise<void> {
|
|||||||
throw new Error('Push notifications are not supported on this device')
|
throw new Error('Push notifications are not supported on this device')
|
||||||
}
|
}
|
||||||
|
|
||||||
const permission = await Notification.requestPermission()
|
// Pre-resolve registration using getRegistrationCompat to prevent ready state hangs
|
||||||
if (permission !== 'granted') {
|
let registration = cachedRegistration
|
||||||
throw new Error('Notification permission denied')
|
if (!registration) {
|
||||||
|
registration = await getRegistrationCompat()
|
||||||
|
cachedRegistration = registration
|
||||||
}
|
}
|
||||||
|
|
||||||
const publicKey = await fetchVapidPublicKey()
|
const publicKey = cachedVapidKey || await fetchVapidPublicKey()
|
||||||
if (!publicKey) {
|
if (!publicKey) {
|
||||||
throw new Error('Push notifications are not configured on this server')
|
throw new Error('Push notifications are not configured on this server')
|
||||||
}
|
}
|
||||||
|
|
||||||
const registration = await navigator.serviceWorker.ready
|
const permission = await requestNotificationPermission()
|
||||||
let subscription = await registration.pushManager.getSubscription()
|
if (permission !== 'granted') {
|
||||||
|
throw new Error('Notification permission denied')
|
||||||
if (!subscription) {
|
|
||||||
const keyBytes = urlBase64ToUint8Array(publicKey)
|
|
||||||
const applicationServerKey = new Uint8Array(keyBytes)
|
|
||||||
subscription = await registration.pushManager.subscribe({
|
|
||||||
userVisibleOnly: true,
|
|
||||||
applicationServerKey
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
await saveSubscriptionToServer(subscription)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function unsubscribeFromPush(): Promise<void> {
|
export async function unsubscribeFromPush(): Promise<void> {
|
||||||
if (!isPushSupported()) return
|
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()
|
const subscription = await registration.pushManager.getSubscription()
|
||||||
if (!subscription) return
|
if (!subscription) return
|
||||||
|
|
||||||
@@ -164,3 +272,7 @@ export async function disableCollaboratorChangePush(): Promise<void> {
|
|||||||
await savePushPrefs(false)
|
await savePushPrefs(false)
|
||||||
await unsubscribeFromPush()
|
await unsubscribeFromPush()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isPushSupported()) {
|
||||||
|
void preloadPushService()
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import { getActiveMasterKey } from './auth.js'
|
|||||||
import { ensureLogbookKey, getLogbookKey } from './logbookKeys.js'
|
import { ensureLogbookKey, getLogbookKey } from './logbookKeys.js'
|
||||||
import { decryptJson, encryptJson } from './crypto.js'
|
import { decryptJson, encryptJson } from './crypto.js'
|
||||||
import { syncLogbook } from './sync.js'
|
import { syncLogbook } from './sync.js'
|
||||||
|
import { putEntryRecord } from '../utils/entryListCache.js'
|
||||||
import {
|
import {
|
||||||
buildLogEntryPayload,
|
buildLogEntryPayload,
|
||||||
normalizeLogEvent,
|
normalizeLogEvent,
|
||||||
sortLogEventsByTime,
|
sortLogEventsByTime,
|
||||||
currentLocalTimeHHMM,
|
currentLocalTimeHHMM,
|
||||||
|
localDateString,
|
||||||
type LogEventPayload
|
type LogEventPayload
|
||||||
} from '../utils/logEntryPayload.js'
|
} from '../utils/logEntryPayload.js'
|
||||||
import {
|
import {
|
||||||
@@ -95,6 +97,14 @@ function buildEncryptedPayload(
|
|||||||
consumption: fuel.consumption ?? 0
|
consumption: fuel.consumption ?? 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const entryCrew = data.selectedSkipperId
|
||||||
|
? {
|
||||||
|
selectedSkipperId: String(data.selectedSkipperId),
|
||||||
|
selectedCrewIds: Array.isArray(data.selectedCrewIds) ? data.selectedCrewIds.map(String) : [],
|
||||||
|
crewSnapshotsById: (data.crewSnapshotsById as Record<string, any>) || {}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
|
||||||
const payload = buildLogEntryPayload({
|
const payload = buildLogEntryPayload({
|
||||||
date: String(data.date || ''),
|
date: String(data.date || ''),
|
||||||
dayOfTravel: String(data.dayOfTravel || ''),
|
dayOfTravel: String(data.dayOfTravel || ''),
|
||||||
@@ -119,15 +129,27 @@ function buildEncryptedPayload(
|
|||||||
motorHoursRaw != null && motorHoursRaw !== ''
|
motorHoursRaw != null && motorHoursRaw !== ''
|
||||||
? parseFloat(String(motorHoursRaw))
|
? parseFloat(String(motorHoursRaw))
|
||||||
: undefined,
|
: undefined,
|
||||||
events: options.events
|
events: options.events,
|
||||||
|
entryCrew
|
||||||
})
|
})
|
||||||
|
|
||||||
const clear = options.clearSignatures
|
const clear = options.clearSignatures
|
||||||
return {
|
const entryData: Record<string, unknown> = {
|
||||||
...payload,
|
...payload,
|
||||||
signSkipper: clear ? '' : (data.signSkipper ?? ''),
|
signSkipper: clear ? '' : (data.signSkipper ?? ''),
|
||||||
signCrew: clear ? '' : (data.signCrew ?? '')
|
signCrew: clear ? '' : (data.signCrew ?? '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const summary = typeof data.aiSummary === 'string' ? data.aiSummary.trim() : ''
|
||||||
|
if (summary) {
|
||||||
|
entryData.aiSummary = summary
|
||||||
|
entryData.aiSummaryGeneratedAt =
|
||||||
|
typeof data.aiSummaryGeneratedAt === 'string' && data.aiSummaryGeneratedAt
|
||||||
|
? data.aiSummaryGeneratedAt
|
||||||
|
: new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
return entryData
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadEntry(logbookId: string, entryId: string): Promise<LoadedEntry | null> {
|
export async function loadEntry(logbookId: string, entryId: string): Promise<LoadedEntry | null> {
|
||||||
@@ -139,18 +161,86 @@ export async function loadEntry(logbookId: string, entryId: string): Promise<Loa
|
|||||||
return { payloadId: record.payloadId, updatedAt: record.updatedAt, data }
|
return { payloadId: record.payloadId, updatedAt: record.updatedAt, data }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scoreTodayEntry(data: Record<string, unknown>): number {
|
||||||
|
const events = (data.events as unknown[] | undefined)?.length ?? 0
|
||||||
|
const signed = (data.signSkipper || data.signCrew) ? 1 : 0
|
||||||
|
const destination = String(data.destination || '').trim() ? 1 : 0
|
||||||
|
return events * 10 + signed + destination
|
||||||
|
}
|
||||||
|
|
||||||
export async function findTodayEntryId(logbookId: string): Promise<string | null> {
|
export async function findTodayEntryId(logbookId: string): Promise<string | null> {
|
||||||
const todayStr = new Date().toISOString().substring(0, 10)
|
const todayStr = localDateString()
|
||||||
const masterKey = await getMasterKey(logbookId)
|
const masterKey = await getMasterKey(logbookId)
|
||||||
const local = sortEntriesNewestFirst(await db.entries.where({ logbookId }).toArray())
|
const local = sortEntriesNewestFirst(await db.entries.where({ logbookId }).toArray())
|
||||||
|
|
||||||
|
let bestId: string | null = null
|
||||||
|
let bestScore = -1
|
||||||
|
let bestUpdatedAt = ''
|
||||||
|
|
||||||
for (const entry of local) {
|
for (const entry of local) {
|
||||||
const decrypted = await tryDecryptEntryPayload(entry, masterKey)
|
const decrypted = await tryDecryptEntryPayload(entry, masterKey)
|
||||||
if (decrypted && String(decrypted.date) === todayStr) {
|
if (!decrypted || String(decrypted.date) !== todayStr) continue
|
||||||
return entry.payloadId
|
|
||||||
|
const score = scoreTodayEntry(decrypted)
|
||||||
|
if (
|
||||||
|
score > bestScore
|
||||||
|
|| (score === bestScore && entry.updatedAt > bestUpdatedAt)
|
||||||
|
) {
|
||||||
|
bestId = entry.payloadId
|
||||||
|
bestScore = score
|
||||||
|
bestUpdatedAt = entry.updatedAt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null
|
|
||||||
|
return bestId
|
||||||
|
}
|
||||||
|
|
||||||
|
async function entryHasAttachments(logbookId: string, entryId: string): Promise<boolean> {
|
||||||
|
const [photos, voices, track] = await Promise.all([
|
||||||
|
db.photos.where({ logbookId, entryId }).count(),
|
||||||
|
db.voiceMemos.where({ logbookId, entryId }).count(),
|
||||||
|
db.gpsTracks.get(entryId)
|
||||||
|
])
|
||||||
|
return photos > 0 || voices > 0 || track != null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isEmptyTodayEntry(
|
||||||
|
logbookId: string,
|
||||||
|
entryId: string,
|
||||||
|
data: Record<string, unknown>
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (((data.events as unknown[] | undefined)?.length ?? 0) > 0) return false
|
||||||
|
if (data.signSkipper || data.signCrew) return false
|
||||||
|
if (String(data.destination || '').trim()) return false
|
||||||
|
return !(await entryHasAttachments(logbookId, entryId))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove duplicate empty travel days for today (e.g. after parallel Live-log init). */
|
||||||
|
export async function pruneEmptyTodayDuplicates(
|
||||||
|
logbookId: string,
|
||||||
|
keepEntryId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const todayStr = localDateString()
|
||||||
|
const masterKey = await getMasterKey(logbookId)
|
||||||
|
const local = await db.entries.where({ logbookId }).toArray()
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
|
for (const entry of local) {
|
||||||
|
if (entry.payloadId === keepEntryId) continue
|
||||||
|
const decrypted = await tryDecryptEntryPayload(entry, masterKey)
|
||||||
|
if (!decrypted || String(decrypted.date) !== todayStr) continue
|
||||||
|
if (!(await isEmptyTodayEntry(logbookId, entry.payloadId, decrypted))) continue
|
||||||
|
|
||||||
|
await db.entries.delete(entry.payloadId)
|
||||||
|
await db.syncQueue.put({
|
||||||
|
action: 'delete',
|
||||||
|
type: 'entry',
|
||||||
|
payloadId: entry.payloadId,
|
||||||
|
logbookId,
|
||||||
|
data: '',
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createTodayEntry(logbookId: string): Promise<string> {
|
export async function createTodayEntry(logbookId: string): Promise<string> {
|
||||||
@@ -173,7 +263,7 @@ export async function createTodayEntry(logbookId: string): Promise<string> {
|
|||||||
|
|
||||||
const localId = window.crypto.randomUUID()
|
const localId = window.crypto.randomUUID()
|
||||||
const nowStr = new Date().toISOString()
|
const nowStr = new Date().toISOString()
|
||||||
const todayStr = nowStr.substring(0, 10)
|
const todayStr = localDateString()
|
||||||
|
|
||||||
const initialPayload = {
|
const initialPayload = {
|
||||||
date: todayStr,
|
date: todayStr,
|
||||||
@@ -190,14 +280,17 @@ export async function createTodayEntry(logbookId: string): Promise<string> {
|
|||||||
|
|
||||||
const encrypted = await encryptJson(initialPayload, masterKey)
|
const encrypted = await encryptJson(initialPayload, masterKey)
|
||||||
|
|
||||||
await db.entries.put({
|
await putEntryRecord(
|
||||||
payloadId: localId,
|
{
|
||||||
logbookId,
|
payloadId: localId,
|
||||||
encryptedData: encrypted.ciphertext,
|
logbookId,
|
||||||
iv: encrypted.iv,
|
encryptedData: encrypted.ciphertext,
|
||||||
tag: encrypted.tag,
|
iv: encrypted.iv,
|
||||||
updatedAt: nowStr
|
tag: encrypted.tag,
|
||||||
})
|
updatedAt: nowStr
|
||||||
|
},
|
||||||
|
initialPayload
|
||||||
|
)
|
||||||
|
|
||||||
await db.syncQueue.put({
|
await db.syncQueue.put({
|
||||||
action: 'create',
|
action: 'create',
|
||||||
@@ -212,20 +305,36 @@ export async function createTodayEntry(logbookId: string): Promise<string> {
|
|||||||
return localId
|
return localId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const findOrCreateTodayEntryInflight = new Map<string, Promise<string>>()
|
||||||
|
|
||||||
|
async function findOrCreateTodayEntryOnce(logbookId: string): Promise<string> {
|
||||||
|
await ensureLogbookKey(logbookId)
|
||||||
|
|
||||||
|
let entryId = await findTodayEntryId(logbookId)
|
||||||
|
if (!entryId) {
|
||||||
|
entryId = await createTodayEntry(logbookId)
|
||||||
|
}
|
||||||
|
|
||||||
|
await pruneEmptyTodayDuplicates(logbookId, entryId)
|
||||||
|
return entryId
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One travel day per local calendar date; concurrent callers share one in-flight create. */
|
||||||
export async function findOrCreateTodayEntry(logbookId: string): Promise<string> {
|
export async function findOrCreateTodayEntry(logbookId: string): Promise<string> {
|
||||||
const id = logbookId.trim()
|
const id = logbookId.trim()
|
||||||
if (!id) throw new Error('Logbook id required')
|
if (!id) throw new Error('Logbook id required')
|
||||||
|
|
||||||
await ensureLogbookKey(id)
|
let inflight = findOrCreateTodayEntryInflight.get(id)
|
||||||
|
if (!inflight) {
|
||||||
const entryCount = await db.entries.where({ logbookId: id }).count()
|
inflight = findOrCreateTodayEntryOnce(id)
|
||||||
if (entryCount === 0) {
|
findOrCreateTodayEntryInflight.set(id, inflight)
|
||||||
return createTodayEntry(id)
|
void inflight.finally(() => {
|
||||||
|
if (findOrCreateTodayEntryInflight.get(id) === inflight) {
|
||||||
|
findOrCreateTodayEntryInflight.delete(id)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
return inflight
|
||||||
const existing = await findTodayEntryId(id)
|
|
||||||
if (existing) return existing
|
|
||||||
return createTodayEntry(id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppendQuickEventResult {
|
export interface AppendQuickEventResult {
|
||||||
@@ -305,14 +414,17 @@ async function persistEntry(
|
|||||||
const encrypted = await encryptJson(entryData, masterKey)
|
const encrypted = await encryptJson(entryData, masterKey)
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
await db.entries.put({
|
await putEntryRecord(
|
||||||
payloadId: entryId,
|
{
|
||||||
logbookId,
|
payloadId: entryId,
|
||||||
encryptedData: encrypted.ciphertext,
|
logbookId,
|
||||||
iv: encrypted.iv,
|
encryptedData: encrypted.ciphertext,
|
||||||
tag: encrypted.tag,
|
iv: encrypted.iv,
|
||||||
updatedAt: now
|
tag: encrypted.tag,
|
||||||
})
|
updatedAt: now
|
||||||
|
},
|
||||||
|
entryData
|
||||||
|
)
|
||||||
|
|
||||||
await db.syncQueue.put({
|
await db.syncQueue.put({
|
||||||
action: 'update',
|
action: 'update',
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -258,14 +258,4 @@ export function getTrackColor(index: number): string {
|
|||||||
return TRACK_COLORS[index % TRACK_COLORS.length]
|
return TRACK_COLORS[index % TRACK_COLORS.length]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatNm(value: number): string {
|
export { formatHours, formatLiters, formatNm } from '../utils/numberFormat.js'
|
||||||
return value.toFixed(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatLiters(value: number): string {
|
|
||||||
return Number.isInteger(value) ? String(value) : value.toFixed(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatHours(value: number): string {
|
|
||||||
return Number.isInteger(value) ? String(value) : value.toFixed(1)
|
|
||||||
}
|
|
||||||
|
|||||||
+175
-18
@@ -2,6 +2,13 @@ import { db, type SyncQueueItem } from './db.js'
|
|||||||
import { getActiveMasterKey } from './auth.js'
|
import { getActiveMasterKey } from './auth.js'
|
||||||
import { apiFetch } from './api.js'
|
import { apiFetch } from './api.js'
|
||||||
import { getLogbookAccess } from './logbookAccess.js'
|
import { getLogbookAccess } from './logbookAccess.js'
|
||||||
|
import {
|
||||||
|
clearSyncConflict,
|
||||||
|
reportSyncConflict,
|
||||||
|
type SyncConflict
|
||||||
|
} from './syncConflicts.js'
|
||||||
|
import { syncPersonPool } from './personPoolSync.js'
|
||||||
|
import { forEachInBatches, yieldToMain } from '../utils/yieldToMain.js'
|
||||||
|
|
||||||
const API_BASE = '/api/sync'
|
const API_BASE = '/api/sync'
|
||||||
const syncingLogbooks = new Set<string>()
|
const syncingLogbooks = new Set<string>()
|
||||||
@@ -54,8 +61,14 @@ async function entityExistsLocally(item: SyncQueueItem): Promise<boolean> {
|
|||||||
return !!(await db.entries.get(item.payloadId))
|
return !!(await db.entries.get(item.payloadId))
|
||||||
case 'photo':
|
case 'photo':
|
||||||
return !!(await db.photos.get(item.payloadId))
|
return !!(await db.photos.get(item.payloadId))
|
||||||
|
case 'voiceMemo':
|
||||||
|
return !!(await db.voiceMemos.get(item.payloadId))
|
||||||
case 'gpsTrack':
|
case 'gpsTrack':
|
||||||
return !!(await db.gpsTracks.get(item.payloadId))
|
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:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -120,12 +133,7 @@ async function coalesceSyncQueue(logbookId: string): Promise<SyncQueueItem[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function scheduleResync(logbookId: string) {
|
function scheduleResync(logbookId: string) {
|
||||||
if (pendingResync.has(logbookId)) return
|
|
||||||
pendingResync.add(logbookId)
|
pendingResync.add(logbookId)
|
||||||
queueMicrotask(() => {
|
|
||||||
pendingResync.delete(logbookId)
|
|
||||||
syncLogbook(logbookId).catch((err) => console.warn('Deferred sync failed:', err))
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type LogbookPushAccess = 'OWNER' | 'WRITE' | 'READ' | 'UNKNOWN'
|
type LogbookPushAccess = 'OWNER' | 'WRITE' | 'READ' | 'UNKNOWN'
|
||||||
@@ -177,10 +185,19 @@ async function pushChanges(logbookId: string): Promise<boolean> {
|
|||||||
const queueItem = pending[i]
|
const queueItem = pending[i]
|
||||||
if (!queueItem) continue
|
if (!queueItem) continue
|
||||||
|
|
||||||
if (res.status === 'success' || res.status === 'conflict') {
|
if (res.status === 'success') {
|
||||||
if (queueItem.id !== undefined) {
|
if (queueItem.id !== undefined) {
|
||||||
await db.syncQueue.delete(queueItem.id)
|
await db.syncQueue.delete(queueItem.id)
|
||||||
}
|
}
|
||||||
|
clearSyncConflict(logbookId, res.payloadId ?? queueItem.payloadId, queueItem.type)
|
||||||
|
} else if (res.status === 'conflict') {
|
||||||
|
reportSyncConflict({
|
||||||
|
logbookId,
|
||||||
|
payloadId: res.payloadId ?? queueItem.payloadId,
|
||||||
|
type: queueItem.type,
|
||||||
|
reason: typeof res.reason === 'string' ? res.reason : 'Server version is newer',
|
||||||
|
queueItemId: queueItem.id
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
console.error(`Sync failed for item ${res.payloadId}:`, res.error)
|
console.error(`Sync failed for item ${res.payloadId}:`, res.error)
|
||||||
}
|
}
|
||||||
@@ -210,9 +227,12 @@ async function flushPushQueue(logbookId: string): Promise<boolean> {
|
|||||||
type PulledServerPayload = {
|
type PulledServerPayload = {
|
||||||
yacht?: { updatedAt: string } | null
|
yacht?: { updatedAt: string } | null
|
||||||
deviation?: { updatedAt: string } | null
|
deviation?: { updatedAt: string } | null
|
||||||
|
logbookCrewSelection?: { updatedAt: string } | null
|
||||||
|
logbookVesselSelection?: { updatedAt: string } | null
|
||||||
crews?: Array<{ payloadId: string; updatedAt: string }>
|
crews?: Array<{ payloadId: string; updatedAt: string }>
|
||||||
entries?: Array<{ payloadId: string; updatedAt: string }>
|
entries?: Array<{ payloadId: string; updatedAt: string }>
|
||||||
photos?: Array<{ payloadId: string; updatedAt: string }>
|
photos?: Array<{ payloadId: string; updatedAt: string }>
|
||||||
|
voiceMemos?: Array<{ payloadId: string; updatedAt: string }>
|
||||||
gpsTracks?: Array<{ entryId: string; updatedAt: string }>
|
gpsTracks?: Array<{ entryId: string; updatedAt: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,9 +247,16 @@ async function pruneAcknowledgedQueueItems(
|
|||||||
const serverTimes = new Map<string, string>()
|
const serverTimes = new Map<string, string>()
|
||||||
if (server.yacht) serverTimes.set('yacht:' + logbookId, server.yacht.updatedAt)
|
if (server.yacht) serverTimes.set('yacht:' + logbookId, server.yacht.updatedAt)
|
||||||
if (server.deviation) serverTimes.set('deviation:' + logbookId, server.deviation.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 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 e of server.entries ?? []) serverTimes.set('entry:' + e.payloadId, e.updatedAt)
|
||||||
for (const p of server.photos ?? []) serverTimes.set('photo:' + p.payloadId, p.updatedAt)
|
for (const p of server.photos ?? []) serverTimes.set('photo:' + p.payloadId, p.updatedAt)
|
||||||
|
for (const v of server.voiceMemos ?? []) serverTimes.set('voiceMemo:' + v.payloadId, v.updatedAt)
|
||||||
for (const gt of server.gpsTracks ?? []) serverTimes.set('gpsTrack:' + gt.entryId, gt.updatedAt)
|
for (const gt of server.gpsTracks ?? []) serverTimes.set('gpsTrack:' + gt.entryId, gt.updatedAt)
|
||||||
|
|
||||||
const localLogbook = await db.logbooks.get(logbookId)
|
const localLogbook = await db.logbooks.get(logbookId)
|
||||||
@@ -243,7 +270,14 @@ async function pruneAcknowledgedQueueItems(
|
|||||||
continue
|
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)
|
const serverUpdatedAt = serverTimes.get(key)
|
||||||
if (serverUpdatedAt && !isNewer(item.updatedAt, serverUpdatedAt)) {
|
if (serverUpdatedAt && !isNewer(item.updatedAt, serverUpdatedAt)) {
|
||||||
if (item.id !== undefined) staleIds.push(item.id)
|
if (item.id !== undefined) staleIds.push(item.id)
|
||||||
@@ -269,8 +303,23 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const { yacht, deviation, crews, entries, photos, gpsTracks } = await response.json()
|
const { yacht, deviation, crews, logbookCrewSelection, logbookVesselSelection, entries, photos, voiceMemos, gpsTracks } =
|
||||||
const serverSnapshot: PulledServerPayload = { yacht, deviation, crews, 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,
|
||||||
|
voiceMemos,
|
||||||
|
gpsTracks
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Sync Yacht Payload
|
// 1. Sync Yacht Payload
|
||||||
if (yacht) {
|
if (yacht) {
|
||||||
@@ -300,10 +349,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>()
|
const serverCrewMap = new Map<string, any>()
|
||||||
if (crews && Array.isArray(crews)) {
|
if (crews && Array.isArray(crews)) {
|
||||||
for (const c of crews) {
|
await forEachInBatches(crews, 20, async (c) => {
|
||||||
serverCrewMap.set(c.payloadId, c)
|
serverCrewMap.set(c.payloadId, c)
|
||||||
const local = await db.crews.get(c.payloadId)
|
const local = await db.crews.get(c.payloadId)
|
||||||
if (!local || isNewer(c.updatedAt, local.updatedAt)) {
|
if (!local || isNewer(c.updatedAt, local.updatedAt)) {
|
||||||
@@ -316,7 +393,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
|||||||
updatedAt: c.updatedAt
|
updatedAt: c.updatedAt
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deletions for Crew: If present locally but not on server, and not pending creation locally
|
// Deletions for Crew: If present locally but not on server, and not pending creation locally
|
||||||
@@ -336,7 +413,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
|||||||
// 4. Sync Journal Entry Payloads
|
// 4. Sync Journal Entry Payloads
|
||||||
const serverEntryMap = new Map<string, any>()
|
const serverEntryMap = new Map<string, any>()
|
||||||
if (entries && Array.isArray(entries)) {
|
if (entries && Array.isArray(entries)) {
|
||||||
for (const e of entries) {
|
await forEachInBatches(entries, 15, async (e) => {
|
||||||
serverEntryMap.set(e.payloadId, e)
|
serverEntryMap.set(e.payloadId, e)
|
||||||
const local = await db.entries.get(e.payloadId)
|
const local = await db.entries.get(e.payloadId)
|
||||||
if (!local || isNewer(e.updatedAt, local.updatedAt)) {
|
if (!local || isNewer(e.updatedAt, local.updatedAt)) {
|
||||||
@@ -349,7 +426,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
|||||||
updatedAt: e.updatedAt
|
updatedAt: e.updatedAt
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deletions for Entries
|
// Deletions for Entries
|
||||||
@@ -368,7 +445,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
|||||||
// 5. Sync Photos
|
// 5. Sync Photos
|
||||||
const serverPhotoMap = new Map<string, any>()
|
const serverPhotoMap = new Map<string, any>()
|
||||||
if (photos && Array.isArray(photos)) {
|
if (photos && Array.isArray(photos)) {
|
||||||
for (const p of photos) {
|
await forEachInBatches(photos, 20, async (p) => {
|
||||||
serverPhotoMap.set(p.payloadId, p)
|
serverPhotoMap.set(p.payloadId, p)
|
||||||
const local = await db.photos.get(p.payloadId)
|
const local = await db.photos.get(p.payloadId)
|
||||||
if (!local || isNewer(p.updatedAt, local.updatedAt)) {
|
if (!local || isNewer(p.updatedAt, local.updatedAt)) {
|
||||||
@@ -383,7 +460,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
|||||||
updatedAt: p.updatedAt
|
updatedAt: p.updatedAt
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deletions for Photos
|
// Deletions for Photos
|
||||||
@@ -399,10 +476,42 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 5b. Sync Voice Memos
|
||||||
|
const serverVoiceMap = new Map<string, any>()
|
||||||
|
if (voiceMemos && Array.isArray(voiceMemos)) {
|
||||||
|
await forEachInBatches(voiceMemos, 20, async (v) => {
|
||||||
|
serverVoiceMap.set(v.payloadId, v)
|
||||||
|
const local = await db.voiceMemos.get(v.payloadId)
|
||||||
|
if (!local || isNewer(v.updatedAt, local.updatedAt)) {
|
||||||
|
await db.voiceMemos.put({
|
||||||
|
payloadId: v.payloadId,
|
||||||
|
entryId: v.entryId,
|
||||||
|
logbookId,
|
||||||
|
encryptedData: v.encryptedData,
|
||||||
|
iv: v.iv,
|
||||||
|
tag: v.tag,
|
||||||
|
updatedAt: v.updatedAt
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const localVoiceMemos = await db.voiceMemos.where({ logbookId }).toArray()
|
||||||
|
for (const lv of localVoiceMemos) {
|
||||||
|
if (!serverVoiceMap.has(lv.payloadId)) {
|
||||||
|
const pendingCreate = await db.syncQueue
|
||||||
|
.where({ payloadId: lv.payloadId, action: 'create' })
|
||||||
|
.first()
|
||||||
|
if (!pendingCreate) {
|
||||||
|
await db.voiceMemos.delete(lv.payloadId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 6. Sync GPS Tracks
|
// 6. Sync GPS Tracks
|
||||||
const serverGpsTrackMap = new Map<string, any>()
|
const serverGpsTrackMap = new Map<string, any>()
|
||||||
if (gpsTracks && Array.isArray(gpsTracks)) {
|
if (gpsTracks && Array.isArray(gpsTracks)) {
|
||||||
for (const gt of gpsTracks) {
|
await forEachInBatches(gpsTracks, 10, async (gt) => {
|
||||||
serverGpsTrackMap.set(gt.entryId, gt)
|
serverGpsTrackMap.set(gt.entryId, gt)
|
||||||
const local = await db.gpsTracks.get(gt.entryId)
|
const local = await db.gpsTracks.get(gt.entryId)
|
||||||
if (!local || isNewer(gt.updatedAt, local.updatedAt)) {
|
if (!local || isNewer(gt.updatedAt, local.updatedAt)) {
|
||||||
@@ -415,7 +524,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
|||||||
updatedAt: gt.updatedAt
|
updatedAt: gt.updatedAt
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deletions for GPS Tracks
|
// Deletions for GPS Tracks
|
||||||
@@ -463,6 +572,12 @@ export async function syncLogbook(logbookId: string): Promise<boolean> {
|
|||||||
} finally {
|
} finally {
|
||||||
syncingLogbooks.delete(logbookId)
|
syncingLogbooks.delete(logbookId)
|
||||||
recomputeSyncingState()
|
recomputeSyncingState()
|
||||||
|
if (pendingResync.has(logbookId)) {
|
||||||
|
pendingResync.delete(logbookId)
|
||||||
|
setTimeout(() => {
|
||||||
|
syncLogbook(logbookId).catch((err) => console.warn('Deferred sync failed:', err))
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,6 +591,8 @@ export async function syncAllLogbooks(): Promise<void> {
|
|||||||
syncAllInFlight++
|
syncAllInFlight++
|
||||||
recomputeSyncingState()
|
recomputeSyncingState()
|
||||||
try {
|
try {
|
||||||
|
await syncPersonPool()
|
||||||
|
|
||||||
// 1. Fetch latest logbook lists first (synchronizes db.logbooks index)
|
// 1. Fetch latest logbook lists first (synchronizes db.logbooks index)
|
||||||
const logbooks = await db.logbooks.toArray()
|
const logbooks = await db.logbooks.toArray()
|
||||||
|
|
||||||
@@ -525,3 +642,43 @@ export function stopBackgroundSync() {
|
|||||||
syncIntervalId = null
|
syncIntervalId = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Accept server version: pull latest and drop the conflicting queue item. */
|
||||||
|
export async function resolveSyncConflictUseServer(conflict: SyncConflict): Promise<void> {
|
||||||
|
if (conflict.queueItemId !== undefined) {
|
||||||
|
await db.syncQueue.delete(conflict.queueItemId)
|
||||||
|
} else {
|
||||||
|
const pending = await db.syncQueue
|
||||||
|
.where({ logbookId: conflict.logbookId })
|
||||||
|
.filter(
|
||||||
|
(item) => item.payloadId === conflict.payloadId && item.type === conflict.type
|
||||||
|
)
|
||||||
|
.toArray()
|
||||||
|
const ids = pending.map((p) => p.id).filter((id): id is number => id !== undefined)
|
||||||
|
if (ids.length > 0) await db.syncQueue.bulkDelete(ids)
|
||||||
|
}
|
||||||
|
clearSyncConflict(conflict.logbookId, conflict.payloadId, conflict.type)
|
||||||
|
await pullChanges(conflict.logbookId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep local version: bump queue timestamp and retry push. */
|
||||||
|
export async function resolveSyncConflictKeepLocal(conflict: SyncConflict): Promise<void> {
|
||||||
|
const bump = new Date(Date.now() + 1000).toISOString()
|
||||||
|
if (conflict.queueItemId !== undefined) {
|
||||||
|
await db.syncQueue.update(conflict.queueItemId, { updatedAt: bump })
|
||||||
|
} else {
|
||||||
|
const pending = await db.syncQueue
|
||||||
|
.where({ logbookId: conflict.logbookId })
|
||||||
|
.filter(
|
||||||
|
(item) => item.payloadId === conflict.payloadId && item.type === conflict.type
|
||||||
|
)
|
||||||
|
.toArray()
|
||||||
|
for (const item of pending) {
|
||||||
|
if (item.id !== undefined) {
|
||||||
|
await db.syncQueue.update(item.id, { updatedAt: bump })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clearSyncConflict(conflict.logbookId, conflict.payloadId, conflict.type)
|
||||||
|
await flushPushQueue(conflict.logbookId)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
export interface SyncConflict {
|
||||||
|
logbookId: string
|
||||||
|
payloadId: string
|
||||||
|
type: string
|
||||||
|
reason: string
|
||||||
|
queueItemId?: number
|
||||||
|
detectedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const conflicts = new Map<string, SyncConflict>()
|
||||||
|
const listeners = new Set<() => void>()
|
||||||
|
|
||||||
|
function conflictKey(logbookId: string, payloadId: string, type: string): string {
|
||||||
|
return `${logbookId}:${type}:${payloadId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSyncConflicts(logbookId?: string): SyncConflict[] {
|
||||||
|
const all = Array.from(conflicts.values())
|
||||||
|
if (!logbookId) return all
|
||||||
|
return all.filter((c) => c.logbookId === logbookId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasSyncConflicts(logbookId?: string): boolean {
|
||||||
|
return getSyncConflicts(logbookId).length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reportSyncConflict(conflict: Omit<SyncConflict, 'detectedAt'>): void {
|
||||||
|
const key = conflictKey(conflict.logbookId, conflict.payloadId, conflict.type)
|
||||||
|
conflicts.set(key, { ...conflict, detectedAt: new Date().toISOString() })
|
||||||
|
listeners.forEach((l) => l())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSyncConflict(logbookId: string, payloadId: string, type: string): void {
|
||||||
|
conflicts.delete(conflictKey(logbookId, payloadId, type))
|
||||||
|
listeners.forEach((l) => l())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSyncConflictsForLogbook(logbookId: string): void {
|
||||||
|
for (const key of conflicts.keys()) {
|
||||||
|
if (key.startsWith(`${logbookId}:`)) conflicts.delete(key)
|
||||||
|
}
|
||||||
|
listeners.forEach((l) => l())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeSyncConflicts(listener: () => void): () => void {
|
||||||
|
listeners.add(listener)
|
||||||
|
return () => listeners.delete(listener)
|
||||||
|
}
|
||||||
@@ -0,0 +1,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,100 @@
|
|||||||
|
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 saveEntryVoiceMemo(options: {
|
||||||
|
logbookId: string
|
||||||
|
entryId: string
|
||||||
|
audioDataUrl: string
|
||||||
|
mimeType: string
|
||||||
|
durationSec: number
|
||||||
|
caption?: string
|
||||||
|
analyticsContext?: string
|
||||||
|
}): Promise<string> {
|
||||||
|
const {
|
||||||
|
logbookId,
|
||||||
|
entryId,
|
||||||
|
audioDataUrl,
|
||||||
|
mimeType,
|
||||||
|
durationSec,
|
||||||
|
caption = '',
|
||||||
|
analyticsContext = 'logbook'
|
||||||
|
} = options
|
||||||
|
const masterKey = await getEncryptionKey(logbookId)
|
||||||
|
const voiceId = window.crypto.randomUUID()
|
||||||
|
const voicePayload = {
|
||||||
|
audio: audioDataUrl,
|
||||||
|
mimeType,
|
||||||
|
durationSec,
|
||||||
|
caption: caption.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
const encrypted = await encryptJson(voicePayload, masterKey)
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
|
await db.voiceMemos.put({
|
||||||
|
payloadId: voiceId,
|
||||||
|
entryId,
|
||||||
|
logbookId,
|
||||||
|
encryptedData: encrypted.ciphertext,
|
||||||
|
iv: encrypted.iv,
|
||||||
|
tag: encrypted.tag,
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
|
||||||
|
await db.syncQueue.put({
|
||||||
|
action: 'create',
|
||||||
|
type: 'voiceMemo',
|
||||||
|
payloadId: voiceId,
|
||||||
|
logbookId,
|
||||||
|
data: JSON.stringify({
|
||||||
|
encryptedData: encrypted.ciphertext,
|
||||||
|
iv: encrypted.iv,
|
||||||
|
tag: encrypted.tag,
|
||||||
|
entryId
|
||||||
|
}),
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
|
||||||
|
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_UPLOADED, { context: analyticsContext })
|
||||||
|
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||||
|
return voiceId
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteEntryVoiceMemo(logbookId: string, voiceId: string): Promise<void> {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
await db.voiceMemos.delete(voiceId)
|
||||||
|
await db.syncQueue.put({
|
||||||
|
action: 'delete',
|
||||||
|
type: 'voiceMemo',
|
||||||
|
payloadId: voiceId,
|
||||||
|
logbookId,
|
||||||
|
data: '',
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deletes the newest voice memo for an entry; returns its id or null. */
|
||||||
|
export async function removeLastVoiceMemoForEntry(
|
||||||
|
logbookId: string,
|
||||||
|
entryId: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
const memos = await db.voiceMemos.where({ entryId }).toArray()
|
||||||
|
if (memos.length === 0) return null
|
||||||
|
memos.sort(
|
||||||
|
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||||
|
)
|
||||||
|
const lastId = memos[0].payloadId
|
||||||
|
await deleteEntryVoiceMemo(logbookId, lastId)
|
||||||
|
return lastId
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
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('throws OFFLINE when navigator.onLine is false', async () => {
|
||||||
|
vi.stubGlobal('navigator', { ...navigator, onLine: false })
|
||||||
|
|
||||||
|
const { fetchOpenWeatherCurrent, WeatherApiError } = await import('./weather.js')
|
||||||
|
const err = await fetchOpenWeatherCurrent({ lat: '54', lon: '10' }).catch((e) => e)
|
||||||
|
expect(err).toBeInstanceOf(WeatherApiError)
|
||||||
|
expect((err as InstanceType<typeof WeatherApiError>).code).toBe('OFFLINE')
|
||||||
|
|
||||||
|
expect(apiFetch).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
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,10 +1,15 @@
|
|||||||
import { apiFetch } from './api.js'
|
import { apiFetch } from './api.js'
|
||||||
import { getOwmApiKeyForActiveUser } from './userPreferences.js'
|
import { getOwmApiKeyForActiveUser } from './userPreferences.js'
|
||||||
|
import {
|
||||||
|
type OwmAnalyticsSource,
|
||||||
|
PlausibleEvents,
|
||||||
|
trackPlausibleEvent
|
||||||
|
} from './analytics.js'
|
||||||
|
|
||||||
export class WeatherApiError extends Error {
|
export class WeatherApiError extends Error {
|
||||||
code: 'NO_KEY' | 'REQUEST_FAILED'
|
code: 'NO_KEY' | 'OFFLINE' | 'REQUEST_FAILED'
|
||||||
|
|
||||||
constructor(message: string, code: 'NO_KEY' | 'REQUEST_FAILED' = 'REQUEST_FAILED') {
|
constructor(message: string, code: 'NO_KEY' | 'OFFLINE' | 'REQUEST_FAILED' = 'REQUEST_FAILED') {
|
||||||
super(message)
|
super(message)
|
||||||
this.name = 'WeatherApiError'
|
this.name = 'WeatherApiError'
|
||||||
this.code = code
|
this.code = code
|
||||||
@@ -13,11 +18,18 @@ export class WeatherApiError extends Error {
|
|||||||
|
|
||||||
const OWM_FETCH_TIMEOUT_MS = 20_000
|
const OWM_FETCH_TIMEOUT_MS = 20_000
|
||||||
|
|
||||||
export async function fetchOpenWeatherCurrent(params: {
|
export async function fetchOpenWeatherCurrent(
|
||||||
lat?: string
|
params: {
|
||||||
lon?: string
|
lat?: string
|
||||||
q?: string
|
lon?: string
|
||||||
}): Promise<Record<string, unknown>> {
|
q?: string
|
||||||
|
},
|
||||||
|
options?: { analyticsSource: OwmAnalyticsSource }
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
if (!navigator.onLine) {
|
||||||
|
throw new WeatherApiError('Offline', 'OFFLINE')
|
||||||
|
}
|
||||||
|
|
||||||
const searchParams = new URLSearchParams()
|
const searchParams = new URLSearchParams()
|
||||||
|
|
||||||
if (params.lat && params.lon) {
|
if (params.lat && params.lon) {
|
||||||
@@ -59,5 +71,11 @@ export async function fetchOpenWeatherCurrent(params: {
|
|||||||
throw new WeatherApiError('Weather API rejected the request')
|
throw new WeatherApiError('Weather API rejected the request')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options?.analyticsSource) {
|
||||||
|
trackPlausibleEvent(PlausibleEvents.OWM_WEATHER_FETCHED, {
|
||||||
|
source: options.analyticsSource
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -6,6 +6,10 @@ import { NetworkFirst, NetworkOnly } from 'workbox-strategies'
|
|||||||
|
|
||||||
declare let self: ServiceWorkerGlobalScope
|
declare let self: ServiceWorkerGlobalScope
|
||||||
|
|
||||||
|
precacheAndRoute(self.__WB_MANIFEST)
|
||||||
|
cleanupOutdatedCaches()
|
||||||
|
clientsClaim()
|
||||||
|
|
||||||
const appShellFallback = createHandlerBoundToURL('/index.html')
|
const appShellFallback = createHandlerBoundToURL('/index.html')
|
||||||
const navigationStrategy = new NetworkFirst({
|
const navigationStrategy = new NetworkFirst({
|
||||||
cacheName: 'app-shell',
|
cacheName: 'app-shell',
|
||||||
@@ -20,10 +24,6 @@ registerRoute(({ request }) => request.mode === 'navigate', async (context) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
precacheAndRoute(self.__WB_MANIFEST)
|
|
||||||
cleanupOutdatedCaches()
|
|
||||||
clientsClaim()
|
|
||||||
|
|
||||||
// Always fetch the live deploy version, even under an older precache.
|
// Always fetch the live deploy version, even under an older precache.
|
||||||
registerRoute(({ url }) => url.pathname === '/version.json', new NetworkOnly())
|
registerRoute(({ url }) => url.pathname === '/version.json', new NetworkOnly())
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
export type PersonRole = 'skipper' | 'crew'
|
||||||
|
|
||||||
|
export interface PersonData {
|
||||||
|
name: string
|
||||||
|
address: string
|
||||||
|
birthDate: string
|
||||||
|
phone: string
|
||||||
|
nationality: string
|
||||||
|
passportNumber: string
|
||||||
|
bloodType: string
|
||||||
|
allergies: string
|
||||||
|
diseases: string
|
||||||
|
role: PersonRole
|
||||||
|
photo?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersonSnapshot {
|
||||||
|
id: string
|
||||||
|
role: PersonRole
|
||||||
|
name: string
|
||||||
|
address: string
|
||||||
|
birthDate: string
|
||||||
|
phone: string
|
||||||
|
nationality: string
|
||||||
|
passportNumber: string
|
||||||
|
bloodType: string
|
||||||
|
allergies: string
|
||||||
|
diseases: string
|
||||||
|
photo?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogbookCrewSelectionData {
|
||||||
|
activeSkipperId: string | null
|
||||||
|
activeCrewIds: string[]
|
||||||
|
/** Denormalized for collaborators / offline display without account pool access */
|
||||||
|
snapshotsById: Record<string, PersonSnapshot>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntryCrewFields {
|
||||||
|
selectedSkipperId: string | null
|
||||||
|
selectedCrewIds: string[]
|
||||||
|
crewSnapshotsById: Record<string, PersonSnapshot>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MAX_POOL_CREW_MEMBERS = 12
|
||||||
|
|
||||||
|
export function emptyLogbookCrewSelection(): LogbookCrewSelectionData {
|
||||||
|
return {
|
||||||
|
activeSkipperId: null,
|
||||||
|
activeCrewIds: [],
|
||||||
|
snapshotsById: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyEntryCrewFields(): EntryCrewFields {
|
||||||
|
return {
|
||||||
|
selectedSkipperId: null,
|
||||||
|
selectedCrewIds: [],
|
||||||
|
crewSnapshotsById: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user