Compare commits
147 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c6ab59d67 | |||
| a9c3e9ce3e | |||
| 3eaf59e2b3 | |||
| b1e17be7fd | |||
| ac7e7c92d1 | |||
| e10cef4b05 | |||
| 0ec5c51102 | |||
| 57b93b7ce7 | |||
| a4b3515711 | |||
| 41acbaebac | |||
| 6c83cd7d36 | |||
| 9089e1c6f9 | |||
| 1504960d85 | |||
| 599f090895 | |||
| 4eb2b4c517 | |||
| be3b23ed8c | |||
| 697c5781b7 | |||
| 4c36c9160a | |||
| d559a762d2 | |||
| a2180a302c | |||
| cd29115233 | |||
| e4b07ca896 | |||
| f0c3cacb06 | |||
| 5821e20086 | |||
| aff8d1517d | |||
| f4d6b11414 | |||
| 968e81f4fb | |||
| 10835c9def | |||
| cdbc618521 | |||
| f75fe42910 | |||
| 212775ffdc | |||
| c80760db02 | |||
| cd1dd12c15 | |||
| 43cf589613 | |||
| e1cb2754c4 | |||
| 5dedb8fac0 | |||
| 78f1659db4 | |||
| 935c263648 | |||
| 29ac96f892 | |||
| 4d3b7210b3 | |||
| 369bca2ef1 | |||
| 2fcc741f5e | |||
| 27722186d1 | |||
| 5710c74706 | |||
| cd27dfa27d | |||
| c4c7d42de4 | |||
| 71025b3d61 | |||
| f790a6adcc | |||
| de5a46938b | |||
| 16944c1a26 | |||
| 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 |
+23
-2
@@ -1,5 +1,11 @@
|
||||
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)
|
||||
# Free plan keys use api-free.deepl.com automatically (suffix :fx)
|
||||
DeepLAPIKey=
|
||||
@@ -9,12 +15,17 @@ DeepLAPIKey=
|
||||
# Production (kapteins-daagbok.eu):
|
||||
# RP_ID=kapteins-daagbok.eu
|
||||
# ORIGIN=https://kapteins-daagbok.eu
|
||||
# Staging (staging.kapteins-daagbok.eu):
|
||||
# RP_ID=staging.kapteins-daagbok.eu
|
||||
# ORIGIN=https://staging.kapteins-daagbok.eu
|
||||
# POSTGRES_DB=daagbox_staging
|
||||
# NTFY_TOPIC=kapteins-daagbok-staging-feedback
|
||||
RP_ID=localhost
|
||||
# Must match the frontend URL exactly (Vite dev: http://localhost:5173; Docker: http://localhost)
|
||||
ORIGIN=http://localhost:5173
|
||||
|
||||
# Behind Nginx Proxy Manager — see docs/deployment/npm-security.md
|
||||
# TRUST_PROXY=172.16.10.10
|
||||
# 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)
|
||||
@@ -30,6 +41,10 @@ ORIGIN=http://localhost:5173
|
||||
# Generate: openssl rand -base64 48
|
||||
SESSION_SECRET=
|
||||
|
||||
# Admin dashboard access — comma-separated list of User IDs (UUIDs)
|
||||
# Example: ADMIN_USER_IDS=11111111-2222-3333-4444-555555555555,22222222-3333-4444-5555-666666666666
|
||||
ADMIN_USER_IDS=
|
||||
|
||||
# Web Push (VAPID) — generate with: npx web-push generate-vapid-keys
|
||||
# Public key may also be set on the client as VITE_VAPID_PUBLIC_KEY
|
||||
VAPID_PUBLIC_KEY=
|
||||
@@ -41,3 +56,9 @@ VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu
|
||||
NTFY_SERVER=https://ntfy.sh
|
||||
NTFY_TOPIC=kapteins-daagbok-feedback
|
||||
NTFY_TOKEN=tk_example_ntfy_access_token
|
||||
|
||||
# Plausible Analytics (frontend container — see docs/plausible-events.md)
|
||||
# Production: PLAUSIBLE_ENABLED=true, data-domain = current hostname (kapteins-daagbok.eu)
|
||||
# Staging: PLAUSIBLE_ENABLED=false (default in docker-compose.staging.yml)
|
||||
PLAUSIBLE_ENABLED=true
|
||||
PLAUSIBLE_HOST=https://plausible.elpatron.me
|
||||
|
||||
@@ -251,15 +251,27 @@ Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen). Führ
|
||||
|
||||
|
||||
```bash
|
||||
./scripts/update-prod.sh
|
||||
./scripts/update-remotes.sh -dest prod
|
||||
```
|
||||
|
||||
Standard-Ziel: `root@10.0.0.25:/opt/kapteins-daagbok` — per `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_DIR` überschreibbar.
|
||||
Standard-Ziel Prod: `root@10.0.0.25:/opt/kapteins-daagbok` — per `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_DIR` überschreibbar.
|
||||
|
||||
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.
|
||||
|
||||
Prod-Deploy legt vor dem Update automatisch ein Server-Backup an (DB, `.env`, Compose, App-Code). Tägliches Cron-Backup und Restore: [docs/deployment/backup.md](docs/deployment/backup.md).
|
||||
|
||||
Hinter **Nginx Proxy Manager**: [docs/deployment/npm-security.md](docs/deployment/npm-security.md).
|
||||
|
||||
### Staging
|
||||
|
||||
Testumgebung unter [staging.kapteins-daagbok.eu](https://staging.kapteins-daagbok.eu) — Deploy ohne Release-Tag:
|
||||
|
||||
```bash
|
||||
./scripts/update-remotes.sh -dest stage
|
||||
```
|
||||
|
||||
Standard-Ziel Staging: `root@10.0.0.27:/opt/kapteins-daagbok-staging` — per `REMOTE_HOST`, `REMOTE_DIR`, `DEPLOY_BRANCH` überschreibbar. Details: [docs/deployment/staging.md](docs/deployment/staging.md).
|
||||
|
||||
## Dokumentation
|
||||
|
||||
| Dokument | Inhalt |
|
||||
@@ -267,6 +279,8 @@ Hinter **Nginx Proxy Manager**: [docs/deployment/npm-security.md](docs/deploymen
|
||||
| [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/deployment/backup.md](docs/deployment/backup.md) | Server-Backup, Crontab, Restore (Prod) |
|
||||
| [docs/deployment/staging.md](docs/deployment/staging.md) | Staging-VM, Deploy, `.env` |
|
||||
| [docs/plausible-events.md](docs/plausible-events.md) | Custom Events für Plausible Analytics |
|
||||
| [docs/push-notifications-plan.md](docs/push-notifications-plan.md) | Web Push: Architektur, API, Testplan |
|
||||
| [docs/plan-compass-course-dial.md](docs/plan-compass-course-dial.md) | Kompass-Dial: UX- und Implementierungsplan |
|
||||
|
||||
+8
-5
@@ -18,15 +18,18 @@ RUN npm run build
|
||||
FROM nginx:1.25-alpine
|
||||
WORKDIR /usr/share/nginx/html
|
||||
|
||||
# Copy custom Nginx configuration
|
||||
COPY client/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
RUN apk add --no-cache gettext
|
||||
|
||||
COPY client/nginx.conf.template /etc/nginx/templates/default.conf.template
|
||||
COPY client/docker-entrypoint.sh /docker-entrypoint.sh
|
||||
RUN chmod +x /docker-entrypoint.sh
|
||||
|
||||
# Copy built assets from builder
|
||||
COPY --from=builder /app/dist .
|
||||
|
||||
# Expose HTTP port
|
||||
EXPOSE 80
|
||||
|
||||
# Health check to verify Nginx is actively running
|
||||
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
|
||||
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
PLAUSIBLE_ENABLED="${PLAUSIBLE_ENABLED:-true}"
|
||||
PLAUSIBLE_HOST="${PLAUSIBLE_HOST:-https://plausible.elpatron.me}"
|
||||
PLAUSIBLE_HOST="${PLAUSIBLE_HOST%/}"
|
||||
|
||||
case "$(printf '%s' "$PLAUSIBLE_ENABLED" | tr '[:upper:]' '[:lower:]')" in
|
||||
true|1|yes)
|
||||
PLAUSIBLE_ENABLED_JSON=true
|
||||
PLAUSIBLE_CSP=" ${PLAUSIBLE_HOST}"
|
||||
;;
|
||||
*)
|
||||
PLAUSIBLE_ENABLED_JSON=false
|
||||
PLAUSIBLE_CSP=""
|
||||
;;
|
||||
esac
|
||||
|
||||
export PLAUSIBLE_CSP
|
||||
envsubst '${PLAUSIBLE_CSP}' < /etc/nginx/templates/default.conf.template > /etc/nginx/conf.d/default.conf
|
||||
|
||||
cat > /usr/share/nginx/html/runtime-config.json <<EOF
|
||||
{"plausibleEnabled":${PLAUSIBLE_ENABLED_JSON},"plausibleHost":"${PLAUSIBLE_HOST}"}
|
||||
EOF
|
||||
|
||||
exec nginx -g 'daemon off;'
|
||||
+2
-1
@@ -22,6 +22,8 @@
|
||||
<meta name="apple-mobile-web-app-title" content="Daagbok" />
|
||||
<meta name="theme-color" content="#0b0c10" />
|
||||
<script src="/appearance-bootstrap.js"></script>
|
||||
<script src="/plausible-bootstrap.js"></script>
|
||||
<script src="/bootstrap-watchdog.js"></script>
|
||||
<link rel="apple-touch-icon" href="/logo.png" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Kapteins Daagbok" />
|
||||
@@ -37,7 +39,6 @@
|
||||
<meta name="twitter:description" content="Kostenlos und werbefrei: sicheres, E2E-verschlüsseltes Logbuch für Skipper. Reisetage, GPS-Tracks, Crew- und Schiffsdaten – Passkey-Anmeldung und Offline-PWA." />
|
||||
<meta name="twitter:image" content="https://kapteins-daagbok.eu/logo.png" />
|
||||
<meta name="twitter:image:alt" content="Kapteins Daagbok Logo" />
|
||||
<script defer data-domain="kapteins-daagbok.eu" src="https://plausible.elpatron.me/js/script.tagged-events.js"></script>
|
||||
<title>Kapteins Daagbok – Kostenloses digitales Yacht-Logbuch (werbefrei)</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+2
-48
@@ -1,48 +1,2 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
client_max_body_size 50M;
|
||||
|
||||
# Security headers (TLS/HSTS at NPM — see docs/deployment/npm-security.md)
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=()" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
|
||||
|
||||
# Service worker and app shell must revalidate so PWA updates are detected
|
||||
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ {
|
||||
root /usr/share/nginx/html;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=()" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
|
||||
}
|
||||
|
||||
location = /index.html {
|
||||
root /usr/share/nginx/html;
|
||||
add_header Cache-Control "no-cache, must-revalidate" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=()" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://plausible.elpatron.me; connect-src 'self' https://plausible.elpatron.me; img-src 'self' data: blob: https://*.tile.openstreetmap.org; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
|
||||
}
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:5000/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
# Generated at container start from PLAUSIBLE_* — see client/nginx.conf.template and docker-entrypoint.sh
|
||||
# Local Docker Compose uses the template via client/Dockerfile entrypoint.
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
client_max_body_size 50M;
|
||||
|
||||
# Security headers (TLS/HSTS at NPM — see docs/deployment/npm-security.md)
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'${PLAUSIBLE_CSP}; connect-src 'self'${PLAUSIBLE_CSP}; 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
|
||||
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ {
|
||||
root /usr/share/nginx/html;
|
||||
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'${PLAUSIBLE_CSP}; connect-src 'self'${PLAUSIBLE_CSP}; 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 {
|
||||
root /usr/share/nginx/html;
|
||||
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'${PLAUSIBLE_CSP}; connect-src 'self'${PLAUSIBLE_CSP}; 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 / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:5000/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
Generated
+1
@@ -12,6 +12,7 @@
|
||||
"bip39": "^3.1.0",
|
||||
"dexie": "^4.4.2",
|
||||
"dexie-react-hooks": "^4.4.0",
|
||||
"fflate": "^0.8.3",
|
||||
"i18next": "^26.3.0",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"jspdf": "^4.2.1",
|
||||
|
||||
+4
-2
@@ -13,6 +13,7 @@
|
||||
"generate:flyer:png": "node ../scripts/generate-beta-flyer.mjs --png",
|
||||
"generate:flyer:all": "node ../scripts/generate-beta-flyer.mjs --all",
|
||||
"generate:flyer:setup": "playwright install chromium",
|
||||
"generate:sharepic": "node ../scripts/generate-sharepic.mjs",
|
||||
"translate:locales": "node ../scripts/translate-locales.mjs",
|
||||
"translate:flyer": "node ../scripts/translate-flyer.mjs",
|
||||
"validate:i18n": "node ../scripts/validate-i18n-keys.mjs"
|
||||
@@ -22,15 +23,16 @@
|
||||
"bip39": "^3.1.0",
|
||||
"dexie": "^4.4.2",
|
||||
"dexie-react-hooks": "^4.4.0",
|
||||
"fflate": "^0.8.3",
|
||||
"i18next": "^26.3.0",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"jspdf": "^4.2.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^1.16.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-i18next": "^17.0.8",
|
||||
"qrcode": "^1.5.4"
|
||||
"react-i18next": "^17.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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 |
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Loads Plausible when enabled via /runtime-config.json (from .env in Docker / Vite dev).
|
||||
* data-domain is always the current hostname (prod vs staging).
|
||||
*/
|
||||
(function () {
|
||||
function load(cfg) {
|
||||
if (!cfg || !cfg.plausibleEnabled || !cfg.plausibleHost) return
|
||||
var host = String(cfg.plausibleHost).replace(/\/$/, '')
|
||||
if (!host) return
|
||||
var s = document.createElement('script')
|
||||
s.defer = true
|
||||
s.dataset.domain = window.location.hostname
|
||||
s.src = host + '/js/script.tagged-events.js'
|
||||
document.head.appendChild(s)
|
||||
}
|
||||
|
||||
fetch('/runtime-config.json', { cache: 'no-store' })
|
||||
.then(function (r) {
|
||||
return r.ok ? r.json() : null
|
||||
})
|
||||
.then(load)
|
||||
.catch(function () {
|
||||
/* analytics optional */
|
||||
})
|
||||
})()
|
||||
+714
-77
File diff suppressed because it is too large
Load Diff
+92
-8
@@ -3,9 +3,11 @@ import { DialogProvider } from './components/ModalDialog.tsx'
|
||||
import AuthOnboarding from './components/AuthOnboarding.tsx'
|
||||
import UserProfilePage from './components/UserProfilePage.tsx'
|
||||
import LogbookDashboard from './components/LogbookDashboard.tsx'
|
||||
import VesselForm from './components/VesselForm.tsx'
|
||||
import LogbookVesselPicker from './components/LogbookVesselPicker.tsx'
|
||||
import LogbookCrewPicker from './components/LogbookCrewPicker.tsx'
|
||||
import { migrateLegacyCrewToPoolIfNeeded } from './services/crewMigration.js'
|
||||
import { migrateLegacyYachtsToPoolIfNeeded } from './services/vesselMigration.js'
|
||||
import { syncVesselPool } from './services/vesselPoolSync.js'
|
||||
import { syncPersonPool } from './services/personPoolSync.js'
|
||||
// Compass Deviation Table — für Freizeit-Skipper vorerst deaktiviert (Komponente bleibt erhalten)
|
||||
// import DeviationForm from './components/DeviationForm.tsx'
|
||||
@@ -34,6 +36,7 @@ import { syncAppearancePrefs } from './services/appearancePrefs.js'
|
||||
import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks, subscribeToSyncState } from './services/sync.js'
|
||||
import ReadOnlyViewer from './components/ReadOnlyViewer.tsx'
|
||||
import DemoViewer from './components/DemoViewer.tsx'
|
||||
import AdminDashboard from './admin/AdminDashboard.tsx'
|
||||
import PwaInstallPrompt from './components/PwaInstallPrompt.tsx'
|
||||
import PwaUpdatePrompt from './components/PwaUpdatePrompt.tsx'
|
||||
import AppFooter from './components/AppFooter.tsx'
|
||||
@@ -47,6 +50,8 @@ import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, La
|
||||
import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
|
||||
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
|
||||
import ProfileHeaderButton from './components/ProfileHeaderButton.tsx'
|
||||
import AdminHeaderButton from './components/AdminHeaderButton.tsx'
|
||||
import { checkAdminAccess } from './services/adminApi.js'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cycleAppLanguage } from './utils/i18nLanguages.js'
|
||||
import {
|
||||
@@ -90,6 +95,10 @@ function App() {
|
||||
|
||||
// Public demo mode (no account required)
|
||||
const [isDemoMode, setIsDemoMode] = useState(() => window.location.pathname === '/demo')
|
||||
const [isAdminRoute, setIsAdminRoute] = useState(() => window.location.pathname.startsWith('/admin'))
|
||||
const [isAdminUser, setIsAdminUser] = useState(false)
|
||||
const [sessionChecked, setSessionChecked] = useState(false)
|
||||
const [serverSessionActive, setServerSessionActive] = useState(false)
|
||||
|
||||
const syncQueueCount = useLiveQuery(
|
||||
() => activeLogbookId ? db.syncQueue.where({ logbookId: activeLogbookId }).count() : db.syncQueue.count(),
|
||||
@@ -101,7 +110,7 @@ function App() {
|
||||
[activeLogbookId]
|
||||
)
|
||||
|
||||
const [activeAccessRole, setActiveAccessRole] = useState<LogbookAccessRole | null>('OWNER')
|
||||
const [activeAccessRole, setActiveAccessRole] = useState<LogbookAccessRole | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeLogbookId) {
|
||||
@@ -158,13 +167,23 @@ function App() {
|
||||
})
|
||||
}, [])
|
||||
|
||||
const refreshAdminAccess = useCallback(async () => {
|
||||
const isAdmin = await checkAdminAccess()
|
||||
setIsAdminUser(isAdmin)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return
|
||||
if (!isAuthenticated) {
|
||||
setIsAdminUser(false)
|
||||
return
|
||||
}
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId) return
|
||||
void syncAppearancePrefs(userId)
|
||||
void migrateLegacyCrewToPoolIfNeeded().then(() => syncPersonPool())
|
||||
}, [isAuthenticated])
|
||||
void migrateLegacyYachtsToPoolIfNeeded().then(() => syncVesselPool())
|
||||
void refreshAdminAccess()
|
||||
}, [isAuthenticated, refreshAdminAccess])
|
||||
|
||||
useEffect(() => {
|
||||
const handleOnline = () => {
|
||||
@@ -196,6 +215,13 @@ function App() {
|
||||
const hashParams = new URLSearchParams(window.location.hash.substring(1))
|
||||
const path = window.location.pathname
|
||||
|
||||
if (path.startsWith('/admin')) {
|
||||
setIsAdminRoute(true)
|
||||
return
|
||||
}
|
||||
|
||||
setIsAdminRoute(false)
|
||||
|
||||
if (path === '/demo') {
|
||||
setIsDemoMode(true)
|
||||
setIsViewerMode(false)
|
||||
@@ -237,6 +263,7 @@ function App() {
|
||||
|
||||
const clearAuthenticatedAppState = useCallback(() => {
|
||||
setIsAuthenticated(false)
|
||||
setIsAdminUser(false)
|
||||
setActiveLogbookId(null)
|
||||
setActiveLogbookTitle(null)
|
||||
setShowUserProfile(false)
|
||||
@@ -246,7 +273,7 @@ function App() {
|
||||
|
||||
/** After PWA/bfcache resume, React state may still say "logged in" while the master key is gone. */
|
||||
const enforceUnlockedSession = useCallback(() => {
|
||||
if (isViewerMode || isDemoMode || isAcceptingInvite) return
|
||||
if (isViewerMode || isDemoMode || isAcceptingInvite || isAdminRoute) return
|
||||
// Require full local session (incl. userId) so API calls are not left headless.
|
||||
if (isAuthenticated && !hasUnlockedLocalSession()) {
|
||||
clearAuthenticatedAppState()
|
||||
@@ -256,6 +283,7 @@ function App() {
|
||||
isViewerMode,
|
||||
isDemoMode,
|
||||
isAcceptingInvite,
|
||||
isAdminRoute,
|
||||
clearAuthenticatedAppState
|
||||
])
|
||||
|
||||
@@ -290,6 +318,8 @@ function App() {
|
||||
const session = await checkServerSession()
|
||||
if (cancelled) return
|
||||
|
||||
setServerSessionActive(session.authenticated)
|
||||
|
||||
if (session.authenticated) {
|
||||
persistSessionUserId(session.userId)
|
||||
}
|
||||
@@ -309,6 +339,10 @@ function App() {
|
||||
if (!cancelled) {
|
||||
console.warn('Session restore failed:', err)
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setSessionChecked(true)
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -330,6 +364,14 @@ function App() {
|
||||
setIsAcceptingInvite(false)
|
||||
}, [])
|
||||
|
||||
const openAdmin = useCallback(() => {
|
||||
window.history.pushState({}, document.title, '/admin')
|
||||
setIsAdminRoute(true)
|
||||
setIsDemoMode(false)
|
||||
setIsViewerMode(false)
|
||||
setIsAcceptingInvite(false)
|
||||
}, [])
|
||||
|
||||
const selectLogbook = useCallback((id: string, title: string) => {
|
||||
setActiveLogbookId(id)
|
||||
setActiveLogbookTitle(title)
|
||||
@@ -494,6 +536,7 @@ function App() {
|
||||
if (!(await confirmLeave())) return
|
||||
void logoutUser()
|
||||
setIsAuthenticated(false)
|
||||
setIsAdminUser(false)
|
||||
setActiveLogbookId(null)
|
||||
setActiveLogbookTitle(null)
|
||||
setShowUserProfile(false)
|
||||
@@ -521,6 +564,28 @@ function App() {
|
||||
syncRouteFromLocation()
|
||||
}
|
||||
|
||||
const handleBackFromAdmin = () => {
|
||||
window.history.replaceState({}, document.title, '/')
|
||||
setIsAdminRoute(false)
|
||||
syncRouteFromLocation()
|
||||
}
|
||||
|
||||
if (isAdminRoute) {
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="auth-screen">
|
||||
<AuthOnboarding onAuthenticated={handleAuthenticated} onOpenDemo={openDemo} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'contents' }}>
|
||||
<AdminDashboard onBack={handleBackFromAdmin} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isDemoMode) {
|
||||
return (
|
||||
<div style={{ display: 'contents' }}>
|
||||
@@ -561,7 +626,17 @@ function App() {
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="auth-screen">
|
||||
<AuthOnboarding onAuthenticated={handleAuthenticated} onOpenDemo={openDemo} />
|
||||
{!sessionChecked ? (
|
||||
<div className="auth-card glass">
|
||||
<p className="dashboard-status-msg">{t('auth.restore_checking')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<AuthOnboarding
|
||||
restoreSession={serverSessionActive}
|
||||
onAuthenticated={handleAuthenticated}
|
||||
onOpenDemo={openDemo}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -571,7 +646,8 @@ function App() {
|
||||
const logbookReadOnly =
|
||||
activeLogbookRecord?.isShared === 1 && activeAccessRole === 'READ'
|
||||
const isLogbookOwner =
|
||||
activeAccessRole === 'OWNER' || activeLogbookRecord?.isShared !== 1
|
||||
activeAccessRole === 'OWNER' ||
|
||||
(activeLogbookRecord != null && activeLogbookRecord.isShared !== 1)
|
||||
|
||||
if (showUserProfile) {
|
||||
return (
|
||||
@@ -593,6 +669,7 @@ function App() {
|
||||
onSelectLogbook={selectLogbook}
|
||||
onLogout={handleLogout}
|
||||
onOpenProfile={() => setShowUserProfile(true)}
|
||||
onOpenAdmin={isAdminUser ? openAdmin : undefined}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -643,6 +720,8 @@ function App() {
|
||||
<Languages size={18} />
|
||||
</button>
|
||||
|
||||
{isAdminUser && <AdminHeaderButton onClick={openAdmin} />}
|
||||
|
||||
<ProfileHeaderButton onClick={() => setShowUserProfile(true)} />
|
||||
|
||||
<DisclaimerHeaderButton />
|
||||
@@ -751,7 +830,12 @@ function App() {
|
||||
)}
|
||||
|
||||
{activeTab === 'vessel' && (
|
||||
<VesselForm logbookId={activeLogbookId} readOnly={logbookReadOnly || !isLogbookOwner} />
|
||||
<LogbookVesselPicker
|
||||
logbookId={activeLogbookId}
|
||||
readOnly={logbookReadOnly || !isLogbookOwner}
|
||||
selectionOnly={!isLogbookOwner && activeLogbookRecord?.isShared === 1}
|
||||
onOpenProfile={isLogbookOwner ? () => setShowUserProfile(true) : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'crew' && (
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
fetchAdminMe,
|
||||
fetchAdminSummary,
|
||||
fetchAdminTimeSeries,
|
||||
type AdminSummary,
|
||||
type AdminTimeSeriesResponse,
|
||||
type AdminTimeBucket
|
||||
} from '../services/adminApi.js'
|
||||
import { BarChart2, Bookmark, ChevronLeft, Image, MapPin, Mic, Users } from 'lucide-react'
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
function KpiCard({
|
||||
icon,
|
||||
label,
|
||||
value
|
||||
}: {
|
||||
icon: ReactNode
|
||||
label: string
|
||||
value: number
|
||||
}) {
|
||||
return (
|
||||
<div className="stats-kpi-card glass">
|
||||
<div className="stats-kpi-icon">{icon}</div>
|
||||
<div className="stats-kpi-body">
|
||||
<span className="stats-kpi-label">{label}</span>
|
||||
<span className="stats-kpi-value">{formatNumber(value)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TimeSeriesChart({
|
||||
title,
|
||||
seriesKey,
|
||||
data
|
||||
}: {
|
||||
title: string
|
||||
seriesKey: string
|
||||
data: AdminTimeSeriesResponse | null
|
||||
}) {
|
||||
if (!data) {
|
||||
return null
|
||||
}
|
||||
|
||||
const metric = data.series.find((s) => s.metric === seriesKey)
|
||||
if (!metric || metric.points.length === 0) {
|
||||
return (
|
||||
<div className="form-card glass">
|
||||
<div className="form-header">
|
||||
<BarChart2 className="form-icon" />
|
||||
<h2>{title}</h2>
|
||||
</div>
|
||||
<p className="dashboard-status-msg">Keine Daten im gewählten Zeitraum.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const max = metric.points.reduce((acc, p) => (p.count > acc ? p.count : acc), 0) || 1
|
||||
|
||||
return (
|
||||
<div className="form-card glass">
|
||||
<div className="form-header">
|
||||
<BarChart2 className="form-icon" />
|
||||
<h2>{title}</h2>
|
||||
</div>
|
||||
<div className="stats-bar-chart" role="img" aria-label={title}>
|
||||
{metric.points.map((point) => {
|
||||
const heightPct = Math.max(2, (point.count / max) * 100)
|
||||
return (
|
||||
<div key={point.date} className="stats-bar-column" title={`${point.date}: ${point.count}`}>
|
||||
<span className="stats-bar-value">{point.count > 0 ? String(point.count) : ''}</span>
|
||||
<div className="stats-bar-track">
|
||||
<div className="stats-bar stats-bar--distance" style={{ height: `${heightPct}%` }} />
|
||||
</div>
|
||||
<span className="stats-bar-label">{point.date.slice(5)}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface AdminDashboardProps {
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
export default function AdminDashboard({ onBack }: AdminDashboardProps) {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [summary, setSummary] = useState<AdminSummary | null>(null)
|
||||
const [timeSeries, setTimeSeries] = useState<AdminTimeSeriesResponse | null>(null)
|
||||
const [bucket, setBucket] = useState<AdminTimeBucket>('day')
|
||||
const [windowDays, setWindowDays] = useState(90)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
await fetchAdminMe()
|
||||
const [summaryRes, tsRes] = await Promise.all([
|
||||
fetchAdminSummary(),
|
||||
fetchAdminTimeSeries({ bucket, windowDays })
|
||||
])
|
||||
|
||||
if (!cancelled) {
|
||||
setSummary(summaryRes)
|
||||
setTimeSeries(tsRes)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (!cancelled) {
|
||||
const message =
|
||||
err instanceof Error && err.message ? err.message : 'Fehler beim Laden des Admin-Dashboards'
|
||||
setError(message)
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [bucket, windowDays])
|
||||
|
||||
if (loading && !summary) {
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<p className="dashboard-status-msg">Admin-Dashboard wird geladen…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<header className="admin-header">
|
||||
<button type="button" className="btn-back" onClick={onBack}>
|
||||
<ChevronLeft size={16} />
|
||||
Zur App
|
||||
</button>
|
||||
</header>
|
||||
<p className="dashboard-status-msg">{error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!summary) {
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<p className="dashboard-status-msg">Keine Admin-Daten verfügbar.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<header className="admin-header">
|
||||
<div className="admin-header-left">
|
||||
<button type="button" className="btn-back" onClick={onBack}>
|
||||
<ChevronLeft size={16} />
|
||||
Zur App
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="admin-title">Admin-Dashboard</h1>
|
||||
<p className="admin-subtitle">
|
||||
Übersicht über Nutzung und Wachstum von Kapteins Daagbok.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="admin-main">
|
||||
<section className="stats-kpi-grid admin-kpi-grid">
|
||||
<KpiCard icon={<Users size={20} />} label="Registrierte Benutzer" value={summary.totalUsers} />
|
||||
<KpiCard icon={<Bookmark size={20} />} label="Logbücher" value={summary.totalLogbooks} />
|
||||
<KpiCard icon={<Image size={20} />} label="Fotos" value={summary.totalPhotos} />
|
||||
<KpiCard icon={<Mic size={20} />} label="Sprachmemos" value={summary.totalVoiceMemos} />
|
||||
<KpiCard icon={<MapPin size={20} />} label="GPS-Tracks" value={summary.totalGpsTracks} />
|
||||
<KpiCard
|
||||
icon={<BarChart2 size={20} />}
|
||||
label="Einträge mit AI-Zusammenfassung"
|
||||
value={summary.aiSummaryEntries}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="admin-controls">
|
||||
<div className="admin-control-group">
|
||||
<span className="admin-control-label">Zeitraum</span>
|
||||
<div className="admin-control-buttons">
|
||||
{[30, 90, 365].map((days) => (
|
||||
<button
|
||||
key={days}
|
||||
type="button"
|
||||
className={days === windowDays ? 'btn primary' : 'btn secondary'}
|
||||
onClick={() => setWindowDays(days)}
|
||||
>
|
||||
{days} Tage
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-control-group">
|
||||
<span className="admin-control-label">Aggregation</span>
|
||||
<div className="admin-control-buttons">
|
||||
{(['day', 'week', 'month'] as AdminTimeBucket[]).map((b) => (
|
||||
<button
|
||||
key={b}
|
||||
type="button"
|
||||
className={b === bucket ? 'btn primary' : 'btn secondary'}
|
||||
onClick={() => setBucket(b)}
|
||||
>
|
||||
{b === 'day' ? 'Tag' : b === 'week' ? 'Woche' : 'Monat'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="admin-charts-grid">
|
||||
<TimeSeriesChart title="Neue Benutzer" seriesKey="users_created" data={timeSeries} />
|
||||
<TimeSeriesChart title="Neue Logbücher" seriesKey="logbooks_created" data={timeSeries} />
|
||||
<TimeSeriesChart title="Foto-Aktivität" seriesKey="photos_updated" data={timeSeries} />
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { LayoutDashboard } from 'lucide-react'
|
||||
|
||||
interface AdminHeaderButtonProps {
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
export default function AdminHeaderButton({ onClick }: AdminHeaderButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon skipper-badge"
|
||||
onClick={onClick}
|
||||
title={t('nav.admin')}
|
||||
aria-label={t('nav.admin')}
|
||||
>
|
||||
<LayoutDashboard size={18} aria-hidden="true" />
|
||||
<span className="skipper-badge__name">{t('nav.admin')}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
import { Coffee, Mail, Compass } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
|
||||
const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'
|
||||
const KOFI_URL = 'https://ko-fi.com/kapteinsdaagbok'
|
||||
|
||||
export default function AppFooter() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<footer className="app-version-footer">
|
||||
<span className="app-version-footer__version">v{APP_VERSION}</span>
|
||||
@@ -10,14 +15,47 @@ export default function AppFooter() {
|
||||
·
|
||||
</span>
|
||||
<span className="app-version-footer__copyright">
|
||||
© 2026 KnorrLabs/
|
||||
<a
|
||||
href="mailto:elpatron+kd@mailbox.org"
|
||||
onClick={() => trackPlausibleEvent(PlausibleEvents.FOOTER_LINK_CLICKED)}
|
||||
>
|
||||
Markus F.J. Busche
|
||||
</a>
|
||||
© 2026
|
||||
</span>
|
||||
<span className="app-version-footer__sep" aria-hidden="true">
|
||||
·
|
||||
</span>
|
||||
<a
|
||||
className="knorrlabs-footer-badge"
|
||||
href="https://dashy.elpatron.me/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => trackPlausibleEvent(PlausibleEvents.FOOTER_LINK_CLICKED)}
|
||||
>
|
||||
<Compass size={14} aria-hidden="true" />
|
||||
<span>KnorrLabs</span>
|
||||
</a>
|
||||
<span className="app-version-footer__sep" aria-hidden="true">
|
||||
·
|
||||
</span>
|
||||
<a
|
||||
className="mail-footer-badge"
|
||||
href="mailto:moin@kapteins-daagbok.eu"
|
||||
onClick={() => trackPlausibleEvent(PlausibleEvents.FOOTER_LINK_CLICKED)}
|
||||
>
|
||||
<Mail size={14} aria-hidden="true" />
|
||||
<span>moin@kapteins-daagbok.eu</span>
|
||||
</a>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
|
||||
import {
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
getKnownUsernames,
|
||||
forgetUsername,
|
||||
hasUnlockedLocalSession,
|
||||
logoutUser
|
||||
logoutUser,
|
||||
resolveRestoreUsername
|
||||
} from '../services/auth.js'
|
||||
import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react'
|
||||
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
|
||||
@@ -27,9 +28,15 @@ import {
|
||||
interface AuthOnboardingProps {
|
||||
onAuthenticated: () => void
|
||||
onOpenDemo?: () => void
|
||||
/** Server session cookie is valid but the in-memory master key was lost (e.g. after reload). */
|
||||
restoreSession?: boolean
|
||||
}
|
||||
|
||||
export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnboardingProps) {
|
||||
export default function AuthOnboarding({
|
||||
onAuthenticated,
|
||||
onOpenDemo,
|
||||
restoreSession = false
|
||||
}: AuthOnboardingProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const [username, setUsername] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -60,7 +67,10 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
||||
const [isNewRegistration, setIsNewRegistration] = useState(false)
|
||||
const [showDisclaimer, setShowDisclaimer] = useState(false)
|
||||
const [showHelp, setShowHelp] = useState(false)
|
||||
const [showStandardLogin, setShowStandardLogin] = useState(false)
|
||||
const autoUnlockAttempted = useRef(false)
|
||||
|
||||
const isRestoreFlow = restoreSession && !showStandardLogin
|
||||
const passkeyHostOk = isPasskeyCompatibleLocation()
|
||||
const passkeyCompatibleUrl = passkeyHostOk ? null : toPasskeyCompatibleUrl(window.location.href)
|
||||
|
||||
@@ -144,6 +154,23 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRestoreFlow || autoUnlockAttempted.current) return
|
||||
|
||||
const user = resolveRestoreUsername()
|
||||
if (user && hasLocalPin(user)) {
|
||||
autoUnlockAttempted.current = true
|
||||
setUsername(user)
|
||||
setShowPinLogin(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (user && passkeyHostOk) {
|
||||
autoUnlockAttempted.current = true
|
||||
void handleLogin(user)
|
||||
}
|
||||
}, [isRestoreFlow, passkeyHostOk])
|
||||
|
||||
const handleRecoverySubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!recoveryInput.trim() || !encryptedPayloads) return
|
||||
@@ -347,10 +374,10 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
||||
<div className="auth-card glass">
|
||||
<div className="auth-header">
|
||||
<KeyRound className="auth-icon accent" size={48} />
|
||||
<h2>{t('auth.enter_pin_title')}</h2>
|
||||
<h2>{isRestoreFlow ? t('auth.restore_title') : t('auth.enter_pin_title')}</h2>
|
||||
</div>
|
||||
<p className="recovery-warning">
|
||||
{t('auth.enter_pin_warning')}
|
||||
{isRestoreFlow ? t('auth.restore_pin_warning') : t('auth.enter_pin_warning')}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handlePinLoginSubmit} className="auth-form">
|
||||
@@ -397,6 +424,12 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => {
|
||||
if (isRestoreFlow) {
|
||||
setShowPinLogin(false)
|
||||
setPinLoginInput('')
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
void (async () => {
|
||||
setShowPinLogin(false)
|
||||
setPinLoginInput('')
|
||||
@@ -480,6 +513,101 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
||||
)
|
||||
}
|
||||
|
||||
// Render: Session restore (active server cookie, master key lost after reload)
|
||||
if (isRestoreFlow) {
|
||||
const restoreUser = resolveRestoreUsername()
|
||||
const restoreKnownUsers = getKnownUsernames()
|
||||
|
||||
return (
|
||||
<div className="auth-card glass">
|
||||
<div className="auth-header">
|
||||
<KeyRound className="auth-icon accent" size={48} />
|
||||
<h2>{t('auth.restore_title')}</h2>
|
||||
</div>
|
||||
<p className="recovery-warning">{t('auth.restore_subtitle')}</p>
|
||||
|
||||
{loading && (
|
||||
<p className="dashboard-status-msg" style={{ marginTop: '12px' }}>
|
||||
{t('auth.restore_unlocking')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && <div className="auth-error">{error}</div>}
|
||||
|
||||
{!loading && (
|
||||
<div className="auth-actions" style={{ flexDirection: 'column', gap: '10px', marginTop: '16px' }}>
|
||||
{restoreUser && passkeyHostOk && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
onClick={() => handleLogin(restoreUser)}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{t('auth.restore_with_passkey', { name: restoreUser })}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{restoreUser && hasLocalPin(restoreUser) && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => {
|
||||
setUsername(restoreUser)
|
||||
setShowPinLogin(true)
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{t('auth.restore_with_pin')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{restoreKnownUsers.length > 1 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', width: '100%' }}>
|
||||
<span style={{ fontSize: '12px', color: '#64748b', textTransform: 'uppercase' }}>
|
||||
{t('auth.quick_login')}
|
||||
</span>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', width: '100%' }}>
|
||||
{restoreKnownUsers.map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (hasLocalPin(name)) {
|
||||
setUsername(name)
|
||||
setShowPinLogin(true)
|
||||
} else {
|
||||
void handleLogin(name)
|
||||
}
|
||||
}}
|
||||
disabled={loading}
|
||||
className="btn secondary"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '6px' }}
|
||||
>
|
||||
<UserRound size={16} />
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => {
|
||||
setShowStandardLogin(true)
|
||||
setError(null)
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{t('auth.restore_other_account')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Render 3: Standard Login / Registration options form
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
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) {
|
||||
let snap: PersonSnapshot | undefined = crewSnapshotsById[creatorId]
|
||||
|
||||
// Fallback: If not found directly by key, search by role or name or active user
|
||||
if (!snap) {
|
||||
if (creatorId === 'skipper') {
|
||||
snap = Object.values(crewSnapshotsById).find((s) => s.role === 'skipper')
|
||||
} else {
|
||||
// Try to match name case-insensitively
|
||||
snap = Object.values(crewSnapshotsById).find(
|
||||
(s) => (s.name || '').trim().toLowerCase() === creatorId.trim().toLowerCase()
|
||||
)
|
||||
|
||||
// Try to match active username/userid to the skipper snapshot
|
||||
if (!snap) {
|
||||
const activeUsername = localStorage.getItem('active_username')
|
||||
const activeUserId = localStorage.getItem('active_userid')
|
||||
if (
|
||||
(activeUsername && creatorId.toLowerCase() === activeUsername.toLowerCase()) ||
|
||||
(activeUserId && creatorId === activeUserId)
|
||||
) {
|
||||
snap = Object.values(crewSnapshotsById).find((s) => s.role === 'skipper')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (snap) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -818,7 +818,7 @@ export default function CrewForm({
|
||||
<button className="btn-icon" onClick={() => openEditMember(m)} title="Edit">
|
||||
<Edit2 size={14} />
|
||||
</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} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
|
||||
import VesselForm from './VesselForm.tsx'
|
||||
import LogbookVesselPicker from './LogbookVesselPicker.tsx'
|
||||
import LogbookCrewPicker from './LogbookCrewPicker.tsx'
|
||||
import type { LogbookCrewSelectionData } from '../types/person.js'
|
||||
import { personToSnapshot } from '../utils/personSnapshots.js'
|
||||
import LogEntriesList from './LogEntriesList.tsx'
|
||||
import { Ship, Users, FileText, Lock, Globe, ChevronLeft, UserPlus } from 'lucide-react'
|
||||
import { buildPublicDemoFixture, type PublicDemoFixture } from '../services/demoLogbookData.js'
|
||||
import type { VesselData } from '../types/vessel.js'
|
||||
import type { LogbookVesselSelectionData } from '../types/vessel.js'
|
||||
import { useAppTour, type AppTab } from '../context/AppTourContext.tsx'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
|
||||
@@ -54,8 +56,18 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
||||
cycleAppLanguage(i18n)
|
||||
}
|
||||
|
||||
const { title, yacht, personPool, logbookCrewSelection, entries, gpsTracks, photos, firstEntryId } =
|
||||
fixture
|
||||
const {
|
||||
title,
|
||||
yacht,
|
||||
vesselPool,
|
||||
logbookVesselSelection,
|
||||
personPool,
|
||||
logbookCrewSelection,
|
||||
entries,
|
||||
gpsTracks,
|
||||
photos,
|
||||
firstEntryId
|
||||
} = fixture
|
||||
|
||||
const demoSelection: LogbookCrewSelectionData = {
|
||||
activeSkipperId: logbookCrewSelection.activeSkipperId,
|
||||
@@ -144,6 +156,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
||||
preloadedYacht={yacht}
|
||||
preloadedEntries={entries}
|
||||
preloadedPhotos={photos}
|
||||
preloadedVoiceMemos={[]}
|
||||
preloadedGpsTracks={gpsTracks}
|
||||
controlledSelectedEntryId={tourSelectedEntryId}
|
||||
onSelectedEntryIdChange={setTourSelectedEntryId}
|
||||
@@ -152,7 +165,15 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
||||
)}
|
||||
|
||||
{activeTab === 'vessel' && (
|
||||
<VesselForm logbookId="demo" readOnly={true} preloadedData={yacht} />
|
||||
<LogbookVesselPicker
|
||||
logbookId="demo"
|
||||
readOnly={true}
|
||||
preloadedPool={vesselPool.map((v) => ({
|
||||
payloadId: v.payloadId,
|
||||
data: v.data as VesselData
|
||||
}))}
|
||||
preloadedSelection={logbookVesselSelection as LogbookVesselSelectionData}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'crew' && (
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getLogbookKey } from '../services/logbookKeys.js'
|
||||
import { encryptJson, decryptJson } from '../services/crypto.js'
|
||||
import { syncLogbook } from '../services/sync.js'
|
||||
import { Compass, Save, Check } from 'lucide-react'
|
||||
import { parseAppDecimalOrZero } from '../utils/numberFormat.js'
|
||||
|
||||
interface DeviationFormProps {
|
||||
logbookId: string
|
||||
@@ -97,8 +98,8 @@ export default function DeviationForm({ logbookId, readOnly = false, preloadedDa
|
||||
const sanitizedDeviations: Record<number, number> = {}
|
||||
headings.forEach((h) => {
|
||||
const val = deviations[h] || ''
|
||||
const parsed = parseFloat(val.replace('+', '').trim())
|
||||
sanitizedDeviations[h] = isNaN(parsed) ? 0 : parsed
|
||||
const parsed = parseAppDecimalOrZero(val.replace('+', '').trim())
|
||||
sanitizedDeviations[h] = parsed
|
||||
})
|
||||
|
||||
const dataToSave = {
|
||||
|
||||
@@ -13,7 +13,12 @@ export default function DisclaimerModal({ open, onClose }: DisclaimerModalProps)
|
||||
if (event.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
const prevOverflow = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
document.body.style.overflow = prevOverflow
|
||||
}
|
||||
}, [open, onClose])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Mic, Loader2 } from 'lucide-react'
|
||||
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'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import { updateVoiceMemoTranscript } from '../services/voiceAttachments.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { getAiAuthorized } from '../services/userPreferences.js'
|
||||
|
||||
interface EventRemarksCellProps {
|
||||
event: LogEventPayload
|
||||
logbookId: string
|
||||
voiceMemoLookup?: Map<string, PreloadedVoiceMemo>
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
export default function EventRemarksCell({
|
||||
event,
|
||||
logbookId,
|
||||
voiceMemoLookup,
|
||||
readOnly = false
|
||||
}: EventRemarksCellProps) {
|
||||
const { t } = useTranslation()
|
||||
const { showAlert } = useDialog()
|
||||
const voiceId = parseLiveVoiceRemark(event.remarks.trim())
|
||||
const preloaded = voiceId ? voiceMemoLookup?.get(voiceId) : undefined
|
||||
|
||||
const [transcribing, setTranscribing] = useState(false)
|
||||
const [isOnline, setIsOnline] = useState(navigator.onLine)
|
||||
|
||||
useEffect(() => {
|
||||
const handleOnline = () => setIsOnline(true)
|
||||
const handleOffline = () => setIsOnline(false)
|
||||
window.addEventListener('online', handleOnline)
|
||||
window.addEventListener('offline', handleOffline)
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline)
|
||||
window.removeEventListener('offline', handleOffline)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleTranscribe = async (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (transcribing || !preloaded?.audio || !voiceId) return
|
||||
if (!getAiAuthorized()) {
|
||||
void showAlert(
|
||||
t('profile.ai_unauthorized_alert_desc'),
|
||||
t('profile.ai_unauthorized_alert_title')
|
||||
)
|
||||
return
|
||||
}
|
||||
setTranscribing(true)
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 15000)
|
||||
try {
|
||||
const res = await fetch('/api/ai/transcribe', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ audioDataUrl: preloaded.audio }),
|
||||
signal: controller.signal
|
||||
})
|
||||
clearTimeout(timeoutId)
|
||||
if (!res.ok) {
|
||||
throw new Error(`Server returned status ${res.status}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
const text = (data.text || '').trim()
|
||||
if (!text) {
|
||||
throw new Error('Transcription returned empty text')
|
||||
}
|
||||
await updateVoiceMemoTranscript(logbookId, voiceId, text)
|
||||
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, {
|
||||
status: 'success',
|
||||
mode: 'manual'
|
||||
})
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId)
|
||||
console.error('[EventRemarksCell] Transcription failed:', err)
|
||||
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, {
|
||||
status: 'failed',
|
||||
mode: 'manual'
|
||||
})
|
||||
void showAlert(t('logs.live_voice_transcribe_failed'), t('logs.live_voice_btn'))
|
||||
} finally {
|
||||
setTranscribing(false)
|
||||
}
|
||||
}
|
||||
|
||||
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 && (
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', flexWrap: 'wrap', gap: '8px', marginTop: '4px' }}>
|
||||
<VoiceMemoPlayer
|
||||
audioId={voiceId}
|
||||
logbookId={logbookId}
|
||||
preloaded={preloaded}
|
||||
compact
|
||||
/>
|
||||
{!readOnly && preloaded && preloaded.transcribed === false && isOnline && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon-text link-sec"
|
||||
style={{
|
||||
fontSize: '0.8rem',
|
||||
padding: '2px 6px',
|
||||
height: 'auto',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
margin: 0
|
||||
}}
|
||||
onClick={handleTranscribe}
|
||||
disabled={transcribing}
|
||||
title={t('logs.live_voice_transcribe_action')}
|
||||
>
|
||||
{transcribing ? (
|
||||
<Loader2 size={12} className="spin" />
|
||||
) : (
|
||||
<Mic size={12} />
|
||||
)}
|
||||
{transcribing ? t('logs.live_voice_transcribing') : t('logs.live_voice_transcribe_action')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useId, useMemo } from 'react'
|
||||
import { joinTimeHHMM, splitTimeHHMM } from '../utils/logEntryPayload.js'
|
||||
import { preferNativeCameraPicker } from '../utils/captureVideoFrame.js'
|
||||
|
||||
const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'))
|
||||
const MINUTES = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'))
|
||||
@@ -18,7 +19,29 @@ export default function EventTimeInput24h({
|
||||
'aria-label': ariaLabel
|
||||
}: EventTimeInput24hProps) {
|
||||
const baseId = useId()
|
||||
const useNativePicker = preferNativeCameraPicker()
|
||||
const { hours, minutes } = useMemo(() => splitTimeHHMM(value), [value])
|
||||
const timeValue = useMemo(() => joinTimeHHMM(hours, minutes), [hours, minutes])
|
||||
|
||||
if (useNativePicker) {
|
||||
return (
|
||||
<div className="time-input-24h">
|
||||
<input
|
||||
id={baseId}
|
||||
type="time"
|
||||
step={60}
|
||||
className="input-text time-input-24h__native"
|
||||
value={timeValue}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value
|
||||
if (next) onChange(next.slice(0, 5))
|
||||
}}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="time-input-24h">
|
||||
|
||||
@@ -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,10 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Camera, X } from 'lucide-react'
|
||||
import {
|
||||
cameraErrorKeyFromDomException,
|
||||
probeCameraAvailability
|
||||
} from '../utils/cameraAvailability.js'
|
||||
import {
|
||||
captureVideoFrame,
|
||||
preferNativeCameraPicker
|
||||
@@ -15,7 +19,7 @@ interface LiveCameraCaptureProps {
|
||||
onCapture: (blob: Blob) => void
|
||||
}
|
||||
|
||||
type Phase = 'live' | 'preview' | 'native'
|
||||
type Phase = 'checking' | 'live' | 'preview' | 'native'
|
||||
|
||||
export default function LiveCameraCapture({
|
||||
open,
|
||||
@@ -34,7 +38,7 @@ export default function LiveCameraCapture({
|
||||
const [cameraError, setCameraError] = useState<string | null>(null)
|
||||
const [ready, setReady] = useState(false)
|
||||
const [capturing, setCapturing] = useState(false)
|
||||
const [phase, setPhase] = useState<Phase>(() => (preferNativeCameraPicker() ? 'native' : 'live'))
|
||||
const [phase, setPhase] = useState<Phase>('checking')
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||
const [previewBlob, setPreviewBlob] = useState<Blob | null>(null)
|
||||
const [streamGeneration, setStreamGeneration] = useState(0)
|
||||
@@ -87,12 +91,37 @@ export default function LiveCameraCapture({
|
||||
clearPreview()
|
||||
setCameraError(null)
|
||||
setCapturing(false)
|
||||
setPhase(preferNativeCameraPicker() ? 'native' : 'live')
|
||||
setPhase('checking')
|
||||
return
|
||||
}
|
||||
setPhase(preferNativeCameraPicker() ? 'native' : 'live')
|
||||
|
||||
let cancelled = false
|
||||
clearPreview()
|
||||
}, [open, stopStream, 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') {
|
||||
@@ -105,11 +134,6 @@ export default function LiveCameraCapture({
|
||||
const start = async () => {
|
||||
setCameraError(null)
|
||||
setReady(false)
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
setCameraError(t('logs.live_photo_camera_unavailable'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
@@ -141,7 +165,7 @@ export default function LiveCameraCapture({
|
||||
} catch (err) {
|
||||
console.error('Camera access failed:', err)
|
||||
if (!cancelled) {
|
||||
setCameraError(t('logs.live_photo_camera_denied'))
|
||||
setCameraError(t(cameraErrorKeyFromDomException(err)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -216,7 +240,7 @@ export default function LiveCameraCapture({
|
||||
className="btn secondary live-camera-close"
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
aria-label={t('logs.confirm_no')}
|
||||
aria-label={t('logs.live_cancel')}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
@@ -243,6 +267,12 @@ export default function LiveCameraCapture({
|
||||
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>
|
||||
@@ -256,7 +286,7 @@ export default function LiveCameraCapture({
|
||||
{t('logs.live_photo_open_camera_btn')}
|
||||
</button>
|
||||
</div>
|
||||
) : cameraError && !ready ? null : (
|
||||
) : phase === 'live' ? (
|
||||
<div className="live-camera-preview-wrap">
|
||||
<video
|
||||
ref={videoRef}
|
||||
@@ -269,7 +299,7 @@ export default function LiveCameraCapture({
|
||||
<p className="live-camera-loading">{t('logs.live_photo_camera_starting')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{onCaptionChange && (
|
||||
<div className="input-group live-camera-caption">
|
||||
@@ -287,7 +317,7 @@ export default function LiveCameraCapture({
|
||||
|
||||
<div className="live-log-modal-actions live-camera-actions">
|
||||
<button type="button" className="btn secondary" onClick={onClose} disabled={busy}>
|
||||
{t('logs.confirm_no')}
|
||||
{t('logs.live_cancel')}
|
||||
</button>
|
||||
|
||||
{showPreview ? (
|
||||
|
||||
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,18 +3,25 @@ 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, encryptJson } from '../services/crypto.js'
|
||||
import { encryptJson } from '../services/crypto.js'
|
||||
import { syncLogbook } from '../services/sync.js'
|
||||
import { downloadCsv, shareCsv } from '../services/csvExport.js'
|
||||
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { getErrorMessage } from '../utils/errors.js'
|
||||
import { findTodayEntryId } from '../services/quickEventLog.js'
|
||||
import { findTodayEntryId, pruneEmptyTodayDuplicates, tryDecryptEntryPayload } from '../services/quickEventLog.js'
|
||||
import { localDateString } from '../utils/logEntryPayload.js'
|
||||
import LogEntryEditor from './LogEntryEditor.tsx'
|
||||
import LiveLogView from './LiveLogView.tsx'
|
||||
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import { getSkipperSignStatus, type SkipperSignStatus } from '../utils/signatures.js'
|
||||
import {
|
||||
buildEntryListCache,
|
||||
entryListItemFromLocal,
|
||||
putEntryRecord
|
||||
} from '../utils/entryListCache.js'
|
||||
import { forEachInBatches } from '../utils/yieldToMain.js'
|
||||
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2, Radio, List } from 'lucide-react'
|
||||
import {
|
||||
carryOverFromPreviousDay,
|
||||
@@ -33,6 +40,7 @@ interface LogEntriesListProps {
|
||||
preloadedYacht?: any
|
||||
preloadedEntries?: any[]
|
||||
preloadedPhotos?: any[]
|
||||
preloadedVoiceMemos?: import('./VoiceMemoPlayer.tsx').PreloadedVoiceMemo[]
|
||||
preloadedGpsTracks?: any[]
|
||||
controlledSelectedEntryId?: string | null
|
||||
onSelectedEntryIdChange?: (id: string | null) => void
|
||||
@@ -57,6 +65,7 @@ export default function LogEntriesList({
|
||||
preloadedYacht,
|
||||
preloadedEntries,
|
||||
preloadedPhotos,
|
||||
preloadedVoiceMemos,
|
||||
preloadedGpsTracks,
|
||||
controlledSelectedEntryId,
|
||||
onSelectedEntryIdChange,
|
||||
@@ -115,25 +124,40 @@ export default function LogEntriesList({
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
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 list: DecryptedEntryItem[] = []
|
||||
|
||||
const needsDecrypt: typeof local = []
|
||||
|
||||
for (const entry of local) {
|
||||
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
|
||||
if (decrypted) {
|
||||
list.push({
|
||||
id: entry.payloadId,
|
||||
date: decrypted.date || '',
|
||||
dayOfTravel: decrypted.dayOfTravel || '',
|
||||
departure: decrypted.departure || '',
|
||||
destination: decrypted.destination || '',
|
||||
updatedAt: entry.updatedAt,
|
||||
skipperSignStatus: await getSkipperSignStatus(decrypted as Record<string, unknown>)
|
||||
})
|
||||
const cached = entryListItemFromLocal(entry)
|
||||
if (cached) {
|
||||
list.push(cached)
|
||||
} else {
|
||||
needsDecrypt.push(entry)
|
||||
}
|
||||
}
|
||||
|
||||
await forEachInBatches(needsDecrypt, 8, async (entry) => {
|
||||
const decrypted = await 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)
|
||||
list.sort((a, b) => {
|
||||
const dateCompare = new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||
@@ -250,7 +274,7 @@ export default function LogEntriesList({
|
||||
const decryptedEntries: Array<LogEntryTankSource & TravelDaySortable> = []
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -282,7 +306,7 @@ export default function LogEntriesList({
|
||||
|
||||
const localId = window.crypto.randomUUID()
|
||||
const nowStr = new Date().toISOString()
|
||||
const todayStr = nowStr.substring(0, 10)
|
||||
const todayStr = localDateString()
|
||||
|
||||
const { loadDefaultEntryCrewForNewDay } = await import('./EntryCrewSection.js')
|
||||
const entryCrew = await loadDefaultEntryCrewForNewDay(
|
||||
@@ -309,14 +333,17 @@ export default function LogEntriesList({
|
||||
const encrypted = await encryptJson(initialPayload, masterKey)
|
||||
|
||||
// Save locally
|
||||
await db.entries.put({
|
||||
payloadId: localId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: nowStr
|
||||
})
|
||||
await putEntryRecord(
|
||||
{
|
||||
payloadId: localId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: nowStr
|
||||
},
|
||||
initialPayload
|
||||
)
|
||||
|
||||
// Queue for background sync
|
||||
await db.syncQueue.put({
|
||||
@@ -384,6 +411,7 @@ export default function LogEntriesList({
|
||||
readOnly={readOnly}
|
||||
preloadedEntry={preloadedEntries?.find(entry => (entry.payloadId || entry.id) === selectedEntryId)}
|
||||
preloadedPhotos={preloadedPhotos}
|
||||
preloadedVoiceMemos={preloadedVoiceMemos}
|
||||
preloadedTrack={preloadedGpsTracks?.find(track => track.entryId === selectedEntryId)}
|
||||
/>
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,10 +5,12 @@ import { useDialog } from './ModalDialog.tsx'
|
||||
import {
|
||||
downloadBackupBlob,
|
||||
exportLogbookBackup,
|
||||
formatBackupBytes,
|
||||
parseLogbookBackupFile,
|
||||
previewLogbookBackup,
|
||||
restoreLogbookBackup,
|
||||
type LogbookBackupFile,
|
||||
BACKUP_SIZE_CONFIRM_BYTES,
|
||||
type ParsedLogbookBackup,
|
||||
type LogbookBackupPreview
|
||||
} from '../services/logbookBackup.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')
|
||||
case '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':
|
||||
return t('settings.backup_invalid_format')
|
||||
case 'BACKUP_NOT_AUTHENTICATED':
|
||||
@@ -53,12 +61,16 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
||||
const [importPassphrase, setImportPassphrase] = useState('')
|
||||
const [importFile, setImportFile] = useState<File | 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 [previewing, setPreviewing] = useState(false)
|
||||
const [exportProgress, setExportProgress] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
|
||||
const exportPassphrasesMatch =
|
||||
exportPassphrase.length >= 8 && exportPassphrase === exportConfirm
|
||||
|
||||
const handleExportSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
await handleExport()
|
||||
@@ -83,21 +95,36 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
||||
}
|
||||
|
||||
setExporting(true)
|
||||
setExportProgress(null)
|
||||
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)
|
||||
setSuccess(t('settings.backup_export_success', { count: backup.counts.entries }))
|
||||
setSuccess(t('settings.backup_export_success', { count: manifest.counts.entries }))
|
||||
setExportPassphrase('')
|
||||
setExportConfirm('')
|
||||
trackPlausibleEvent(PlausibleEvents.BACKUP_EXPORTED, {
|
||||
entries: backup.counts.entries,
|
||||
photos: backup.counts.photos
|
||||
entries: manifest.counts.entries,
|
||||
photos: manifest.counts.photos,
|
||||
voiceMemos: manifest.counts.voiceMemos,
|
||||
bytes: manifest.totalUncompressedBytes
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
setError(mapBackupError(message, t))
|
||||
} finally {
|
||||
setExporting(false)
|
||||
setExportProgress(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +165,18 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
||||
const handleRestore = async (options: { overwrite?: boolean; assignNewId?: boolean } = {}) => {
|
||||
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)
|
||||
setError(null)
|
||||
try {
|
||||
@@ -149,8 +188,10 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
||||
setParsedBackup(null)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
trackPlausibleEvent(PlausibleEvents.BACKUP_RESTORED, {
|
||||
entries: parsedBackup.counts.entries,
|
||||
photos: parsedBackup.counts.photos,
|
||||
entries: parsedBackup.manifest.counts.entries,
|
||||
photos: parsedBackup.manifest.counts.photos,
|
||||
voiceMemos: parsedBackup.manifest.counts.voiceMemos,
|
||||
bytes: parsedBackup.manifest.totalUncompressedBytes,
|
||||
mode: options.overwrite ? 'overwrite' : options.assignNewId ? 'new_id' : 'same_id'
|
||||
})
|
||||
onRestored?.(result.logbookId, result.title)
|
||||
@@ -253,11 +294,16 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
||||
<button
|
||||
type="submit"
|
||||
className="btn primary"
|
||||
disabled={exporting || !exportPassphrase || !exportConfirm}
|
||||
disabled={exporting || !exportPassphrasesMatch}
|
||||
>
|
||||
<Download size={16} />
|
||||
{exporting ? t('settings.backup_exporting') : t('settings.backup_export_btn')}
|
||||
</button>
|
||||
{exportProgress && (
|
||||
<p className="text-muted backup-export-progress" role="status">
|
||||
{exportProgress}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -275,7 +321,7 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
||||
id="backup-import-file"
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".daagbok.json,application/json"
|
||||
accept=".daagbok,application/zip"
|
||||
className="input-text"
|
||||
onChange={handleFileChange}
|
||||
disabled={importing}
|
||||
@@ -330,8 +376,14 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
||||
<ul className="backup-preview-stats">
|
||||
<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_voice', { count: importPreview.counts.voiceMemos })}</li>
|
||||
<li>{t('settings.backup_stat_crew', { count: importPreview.counts.crews })}</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>
|
||||
<p className="text-muted backup-preview-date">
|
||||
{t('settings.backup_exported_at', {
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useTranslation } from 'react-i18next'
|
||||
import { cycleAppLanguage } from '../utils/i18nLanguages.js'
|
||||
import { useSyncIndicator } from '../hooks/useSyncIndicator.js'
|
||||
import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type DecryptedLogbook } from '../services/logbook.js'
|
||||
import { loadLogbookSearchFieldsBatch } from '../services/logbookSearchIndex.js'
|
||||
import { logbookMatchesFilter, type LogbookSearchFields } from '../utils/logbookFilter.js'
|
||||
import LogbookRoleBadge from './LogbookRoleBadge.tsx'
|
||||
import BetaBadge from './BetaBadge.tsx'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
@@ -13,31 +15,13 @@ import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, Wifi, WifiO
|
||||
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
|
||||
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
|
||||
import ProfileHeaderButton from './ProfileHeaderButton.tsx'
|
||||
import AdminHeaderButton from './AdminHeaderButton.tsx'
|
||||
|
||||
interface LogbookDashboardProps {
|
||||
onSelectLogbook: (id: string, title: string) => void
|
||||
onLogout: () => 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
|
||||
onOpenAdmin?: () => void
|
||||
}
|
||||
|
||||
type LogbookSortKey = 'name' | 'date'
|
||||
@@ -60,7 +44,7 @@ function sortLogbooks(
|
||||
return sorted
|
||||
}
|
||||
|
||||
export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProfile }: LogbookDashboardProps) {
|
||||
export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProfile, onOpenAdmin }: LogbookDashboardProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { showConfirm } = useDialog()
|
||||
const [logbooks, setLogbooks] = useState<DecryptedLogbook[]>([])
|
||||
@@ -72,6 +56,9 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [filterQuery, setFilterQuery] = useState('')
|
||||
const [searchFieldsByLogbookId, setSearchFieldsByLogbookId] = useState<Map<string, LogbookSearchFields>>(
|
||||
() => new Map()
|
||||
)
|
||||
const [sortBy, setSortBy] = useState<LogbookSortKey>('date')
|
||||
const [sortDirection, setSortDirection] = useState<LogbookSortDirection>('desc')
|
||||
const filterInputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -96,6 +83,23 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
||||
loadLogbooks()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const ids = logbooks.map((lb) => lb.id)
|
||||
if (ids.length === 0) {
|
||||
setSearchFieldsByLogbookId(new Map())
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
void loadLogbookSearchFieldsBatch(ids).then((index) => {
|
||||
if (!cancelled) setSearchFieldsByLogbookId(index)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [logbooks])
|
||||
|
||||
const loadLogbooks = async (isRefresh = false) => {
|
||||
if (isRefresh) setRefreshing(true)
|
||||
else setLoading(true)
|
||||
@@ -203,12 +207,18 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
||||
|
||||
const filterActive = filterQuery.trim().length > 0
|
||||
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(
|
||||
() => 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(
|
||||
() => sortLogbooks(filteredOwnedLogbooks, sortBy, sortDirection, i18n.language),
|
||||
@@ -380,6 +390,8 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
||||
|
||||
<ProfileHeaderButton onClick={onOpenProfile} />
|
||||
|
||||
{onOpenAdmin && <AdminHeaderButton onClick={onOpenAdmin} />}
|
||||
|
||||
{/* Lang toggle */}
|
||||
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
|
||||
<Languages size={18} />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -10,9 +10,19 @@ import React, {
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export type ConfirmLeaveChoice = 'stay' | 'save' | 'discard'
|
||||
|
||||
interface DialogContextType {
|
||||
showAlert: (message: string, title?: string, confirmText?: string) => Promise<void>
|
||||
showConfirm: (message: string, title?: string, confirmText?: string, cancelText?: string) => Promise<boolean>
|
||||
showConfirmLeave: (
|
||||
message: string,
|
||||
title?: string,
|
||||
stayLabel?: string,
|
||||
saveLabel?: string,
|
||||
discardLabel?: string,
|
||||
options?: { showSave?: boolean }
|
||||
) => Promise<ConfirmLeaveChoice>
|
||||
}
|
||||
|
||||
const DialogContext = createContext<DialogContextType | undefined>(undefined)
|
||||
@@ -34,12 +44,16 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [title, setTitle] = useState('')
|
||||
const [message, setMessage] = useState('')
|
||||
const [type, setType] = useState<'alert' | 'confirm'>('alert')
|
||||
const [type, setType] = useState<'alert' | 'confirm' | 'confirm-leave'>('alert')
|
||||
const [confirmLabel, setConfirmLabel] = useState('OK')
|
||||
const [cancelLabel, setCancelLabel] = useState('Cancel')
|
||||
const [saveLabel, setSaveLabel] = useState('')
|
||||
const [discardLabel, setDiscardLabel] = useState('')
|
||||
const [showSaveOption, setShowSaveOption] = useState(false)
|
||||
|
||||
const alertResolveRef = useRef<(() => void) | null>(null)
|
||||
const confirmResolveRef = useRef<((val: boolean) => void) | null>(null)
|
||||
const confirmLeaveResolveRef = useRef<((val: ConfirmLeaveChoice) => void) | null>(null)
|
||||
|
||||
const showAlert = useCallback((msg: string, headerTitle?: string, btnText?: string): Promise<void> => {
|
||||
setMessage(msg)
|
||||
@@ -71,6 +85,36 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
|
||||
})
|
||||
}, [t])
|
||||
|
||||
const showConfirmLeave = useCallback((
|
||||
msg: string,
|
||||
headerTitle?: string,
|
||||
btnStay?: string,
|
||||
btnSave?: string,
|
||||
btnDiscard?: string,
|
||||
options?: { showSave?: boolean }
|
||||
): Promise<ConfirmLeaveChoice> => {
|
||||
setMessage(msg)
|
||||
setTitle(headerTitle || '')
|
||||
setType('confirm-leave')
|
||||
setCancelLabel(btnStay || t('common.unsaved_changes_stay'))
|
||||
setSaveLabel(btnSave || t('common.unsaved_changes_save_leave'))
|
||||
setDiscardLabel(btnDiscard || t('common.unsaved_changes_discard'))
|
||||
setShowSaveOption(options?.showSave !== false)
|
||||
setIsOpen(true)
|
||||
|
||||
return new Promise<ConfirmLeaveChoice>((resolve) => {
|
||||
confirmLeaveResolveRef.current = resolve
|
||||
})
|
||||
}, [t])
|
||||
|
||||
const closeConfirmLeave = useCallback((choice: ConfirmLeaveChoice) => {
|
||||
setIsOpen(false)
|
||||
if (confirmLeaveResolveRef.current) {
|
||||
confirmLeaveResolveRef.current(choice)
|
||||
confirmLeaveResolveRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
setIsOpen(false)
|
||||
if (type === 'confirm' && confirmResolveRef.current) {
|
||||
@@ -83,19 +127,23 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
|
||||
}, [type])
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (type === 'confirm-leave') {
|
||||
closeConfirmLeave('stay')
|
||||
return
|
||||
}
|
||||
setIsOpen(false)
|
||||
if (confirmResolveRef.current) {
|
||||
confirmResolveRef.current(false)
|
||||
confirmResolveRef.current = null
|
||||
}
|
||||
}, [])
|
||||
}, [type, closeConfirmLeave])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
confirmRef.current?.focus()
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (type === 'confirm') handleCancel()
|
||||
if (type === 'confirm' || type === 'confirm-leave') handleCancel()
|
||||
else handleConfirm()
|
||||
}
|
||||
}
|
||||
@@ -104,8 +152,8 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
|
||||
}, [isOpen, type, handleCancel, handleConfirm])
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({ showAlert, showConfirm }),
|
||||
[showAlert, showConfirm]
|
||||
() => ({ showAlert, showConfirm, showConfirmLeave }),
|
||||
[showAlert, showConfirm, showConfirmLeave]
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -114,7 +162,7 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
|
||||
{isOpen && (
|
||||
<div
|
||||
className="custom-dialog-overlay"
|
||||
onClick={type === 'confirm' ? handleCancel : handleConfirm}
|
||||
onClick={type === 'confirm' || type === 'confirm-leave' ? handleCancel : handleConfirm}
|
||||
>
|
||||
<div
|
||||
className="custom-dialog-card glass scale-in"
|
||||
@@ -133,25 +181,59 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
|
||||
{message}
|
||||
</p>
|
||||
<div className="custom-dialog-actions">
|
||||
{type === 'confirm' && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={handleCancel}
|
||||
style={{ width: 'auto', padding: '8px 20px', margin: 0 }}
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
{type === 'confirm-leave' ? (
|
||||
<>
|
||||
<button
|
||||
ref={confirmRef}
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={handleCancel}
|
||||
style={{ width: 'auto', padding: '8px 20px', margin: 0 }}
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
{showSaveOption && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
onClick={() => closeConfirmLeave('save')}
|
||||
style={{ width: 'auto', padding: '8px 20px', margin: 0 }}
|
||||
>
|
||||
{saveLabel}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn danger"
|
||||
onClick={() => closeConfirmLeave('discard')}
|
||||
style={{ width: 'auto', padding: '8px 20px', margin: 0 }}
|
||||
>
|
||||
{discardLabel}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{type === 'confirm' && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={handleCancel}
|
||||
style={{ width: 'auto', padding: '8px 20px', margin: 0 }}
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
ref={confirmRef}
|
||||
type="button"
|
||||
className="btn primary"
|
||||
onClick={handleConfirm}
|
||||
style={{ width: 'auto', minWidth: '80px', padding: '8px 20px', margin: 0 }}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
ref={confirmRef}
|
||||
type="button"
|
||||
className="btn primary"
|
||||
onClick={handleConfirm}
|
||||
style={{ width: 'auto', minWidth: '80px', padding: '8px 20px', margin: 0 }}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +147,7 @@ export default function PersonPoolForm() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon logout"
|
||||
className="btn-icon danger"
|
||||
onClick={() => void handleDelete(person.payloadId)}
|
||||
title="Delete"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
enableCollaboratorChangePush,
|
||||
fetchPushPrefs,
|
||||
getNotificationPermission,
|
||||
isPushSupported
|
||||
isPushSupported,
|
||||
preloadPushService
|
||||
} from '../services/pushNotifications.js'
|
||||
import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
@@ -28,6 +29,7 @@ export default function PushNotificationSettings() {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
void preloadPushService()
|
||||
try {
|
||||
const prefs = await fetchPushPrefs()
|
||||
setEnabled(prefs.collaboratorChangesEnabled)
|
||||
@@ -56,7 +58,8 @@ export default function PushNotificationSettings() {
|
||||
trackPlausibleEvent(PlausibleEvents.PUSH_DISABLED)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : t('profile.push_error')
|
||||
console.error('Failed to toggle push notifications:', err)
|
||||
const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err)
|
||||
showAlert(message)
|
||||
void loadPrefs()
|
||||
} finally {
|
||||
|
||||
@@ -3,8 +3,10 @@ import { useTranslation } from 'react-i18next'
|
||||
import { cycleAppLanguage, getNextLanguage, isGermanLocale } from '../utils/i18nLanguages.js'
|
||||
import { decryptJson } from '../services/crypto.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import VesselForm from './VesselForm.tsx'
|
||||
import LogbookVesselPicker from './LogbookVesselPicker.tsx'
|
||||
import LogbookCrewPicker from './LogbookCrewPicker.tsx'
|
||||
import type { LogbookVesselSelectionData } from '../types/vessel.js'
|
||||
import { emptyLogbookVesselSelection } from '../types/vessel.js'
|
||||
import type { LogbookCrewSelectionData } from '../types/person.js'
|
||||
import { emptyLogbookCrewSelection } from '../types/person.js'
|
||||
import { legacyCrewRecordsToLogbookSelection } from '../utils/personSnapshots.js'
|
||||
@@ -38,9 +40,13 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
||||
const [logbookCrewSelection, setLogbookCrewSelection] = useState<LogbookCrewSelectionData>(
|
||||
emptyLogbookCrewSelection()
|
||||
)
|
||||
const [logbookVesselSelection, setLogbookVesselSelection] = useState<LogbookVesselSelectionData>(
|
||||
emptyLogbookVesselSelection()
|
||||
)
|
||||
const [legacyCrews, setLegacyCrews] = useState<any[]>([])
|
||||
const [entries, setEntries] = useState<any[]>([])
|
||||
const [photos, setPhotos] = useState<any[]>([])
|
||||
const [voiceMemos, setVoiceMemos] = useState<any[]>([])
|
||||
const [gpsTracks, setGpsTracks] = useState<any[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -97,6 +103,31 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -144,6 +175,23 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
||||
}
|
||||
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
|
||||
const decGpsTracks = []
|
||||
if (data.gpsTracks) {
|
||||
@@ -252,15 +300,17 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
||||
preloadedYacht={yacht}
|
||||
preloadedEntries={entries}
|
||||
preloadedPhotos={photos}
|
||||
preloadedVoiceMemos={voiceMemos}
|
||||
preloadedGpsTracks={gpsTracks}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'vessel' && (
|
||||
<VesselForm
|
||||
<LogbookVesselPicker
|
||||
logbookId="shared"
|
||||
readOnly={true}
|
||||
preloadedData={yacht}
|
||||
selectionOnly={true}
|
||||
preloadedSelection={logbookVesselSelection}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -28,19 +28,19 @@ export default function RegistrationDisclaimer({
|
||||
className={`auth-card glass registration-disclaimer${variant === 'view' ? ' registration-disclaimer--modal' : ''}`}
|
||||
role="document"
|
||||
>
|
||||
{variant === 'view' && (
|
||||
<button
|
||||
type="button"
|
||||
className="registration-disclaimer__close"
|
||||
onClick={onDismiss}
|
||||
aria-label={t('disclaimer.close')}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
)}
|
||||
<div className="auth-header">
|
||||
<ScrollText className="auth-icon accent" size={48} />
|
||||
<h2>{t('disclaimer.title')}</h2>
|
||||
{variant === 'view' && (
|
||||
<button
|
||||
type="button"
|
||||
className="registration-disclaimer__close"
|
||||
onClick={onDismiss}
|
||||
aria-label={t('disclaimer.close')}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="registration-disclaimer__intro">{t('disclaimer.intro')}</p>
|
||||
|
||||
@@ -10,7 +10,8 @@ import { apiFetch } from '../services/api.js'
|
||||
import {
|
||||
enableCollaboratorChangePush,
|
||||
isCollaboratorPushActive,
|
||||
isPushSupported
|
||||
isPushSupported,
|
||||
preloadPushService
|
||||
} from '../services/pushNotifications.js'
|
||||
import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
|
||||
|
||||
@@ -55,6 +56,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
||||
loadCollaborators()
|
||||
loadShareLink()
|
||||
}
|
||||
void preloadPushService()
|
||||
}, [logbookId])
|
||||
|
||||
const loadShareLink = async () => {
|
||||
@@ -191,7 +193,8 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
||||
trackPlausibleEvent(PlausibleEvents.PUSH_ENABLED)
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to enable push after invite:', err)
|
||||
await showAlert(err instanceof Error ? err.message : t('profile.push_error'))
|
||||
const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err)
|
||||
await showAlert(message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,7 +429,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon logout"
|
||||
className="btn-icon danger"
|
||||
onClick={() => handleRevoke(c.id, c.username)}
|
||||
title="Revoke access"
|
||||
>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '../services/statsAggregation.js'
|
||||
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
|
||||
import { formatFuelPerMotorHour } from '../utils/fuelStats.js'
|
||||
import { formatAppDecimal } from '../utils/numberFormat.js'
|
||||
import {
|
||||
loadLogbookEventSeries,
|
||||
type EventSeriesPoint,
|
||||
@@ -211,8 +212,8 @@ function PropulsionBreakdown({ totals }: { totals: StatsTotals }) {
|
||||
)}
|
||||
</div>
|
||||
<div className="stats-propulsion-labels">
|
||||
<span>{t('stats.sail_distance')}: {formatNm(totals.sailDistanceNm)} {t('stats.unit_nm')} ({sailPct.toFixed(0)}%)</span>
|
||||
<span>{t('stats.motor_distance')}: {formatNm(totals.motorDistanceNm)} {t('stats.unit_nm')} ({motorPct.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')} ({formatAppDecimal(motorPct, { maximumFractionDigits: 0 })}%)</span>
|
||||
{totals.unknownPropulsionNm > 0 && (
|
||||
<span>{t('stats.unknown_propulsion')}: {formatNm(totals.unknownPropulsionNm)} {t('stats.unit_nm')}</span>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { clampTankLiters } from '../utils/tankCapacity.js'
|
||||
import { formatTankLiters, parseAppDecimalOrZero } from '../utils/numberFormat.js'
|
||||
|
||||
interface TankLiterInputProps {
|
||||
id?: string
|
||||
@@ -13,10 +14,8 @@ interface TankLiterInputProps {
|
||||
}
|
||||
|
||||
function parseInputLiters(value: string): number {
|
||||
const trimmed = value.trim().replace(',', '.')
|
||||
if (!trimmed) return 0
|
||||
const parsed = Number(trimmed)
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
if (!value.trim()) return 0
|
||||
return parseAppDecimalOrZero(value)
|
||||
}
|
||||
|
||||
export default function TankLiterInput({
|
||||
@@ -34,8 +33,7 @@ export default function TankLiterInput({
|
||||
const emitValue = useCallback(
|
||||
(liters: number) => {
|
||||
const clamped = clampTankLiters(liters, useSlider ? maxLiters : undefined)
|
||||
const str =
|
||||
Number.isInteger(clamped) ? String(clamped) : String(Number(clamped.toFixed(1)))
|
||||
const str = formatTankLiters(clamped)
|
||||
onChange(str)
|
||||
},
|
||||
[onChange, maxLiters, useSlider]
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Anchor,
|
||||
Gauge,
|
||||
Sailboat,
|
||||
Ship,
|
||||
Timer,
|
||||
Share2,
|
||||
Calendar,
|
||||
@@ -31,6 +32,9 @@ import {
|
||||
import AccountDangerZone from './AccountDangerZone.tsx'
|
||||
import UserProfilePreferences from './UserProfilePreferences.tsx'
|
||||
import PersonPoolForm from './PersonPoolForm.tsx'
|
||||
import VesselPoolForm from './VesselPoolForm.tsx'
|
||||
import ProfileAccordionSection from './ProfileAccordionSection.tsx'
|
||||
import { useAppTour } from '../context/AppTourContext.tsx'
|
||||
import BetaBadge from './BetaBadge.tsx'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import {
|
||||
@@ -137,6 +141,11 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
||||
connStatusClassName
|
||||
} = useSyncIndicator()
|
||||
|
||||
const { isActive: tourActive, currentStepId: tourStepId } = useAppTour()
|
||||
const fleetSectionTourOpen =
|
||||
tourActive &&
|
||||
(tourStepId === 'profile_vessel_pool' || tourStepId === 'profile_crew_pool')
|
||||
|
||||
const sharedLogbookCount = useLiveQuery(
|
||||
() => db.logbooks.filter((lb) => lb.isShared === 1).count(),
|
||||
[]
|
||||
@@ -444,8 +453,14 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
||||
</section>
|
||||
) : profile ? (
|
||||
<>
|
||||
<ProfileAccordionSection
|
||||
id="account"
|
||||
title={t('profile.sections.account')}
|
||||
icon={<User size={20} aria-hidden="true" />}
|
||||
defaultOpen
|
||||
>
|
||||
<div data-tour="profile-preferences">
|
||||
<section className="form-card">
|
||||
<section className="form-card profile-accordion-inner-card">
|
||||
<div className="form-header">
|
||||
<User size={24} className="form-icon" />
|
||||
<h2>{t('profile.identity_title')}</h2>
|
||||
@@ -487,10 +502,25 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
||||
|
||||
<UserProfilePreferences userId={profile.userId} />
|
||||
</div>
|
||||
</ProfileAccordionSection>
|
||||
|
||||
<ProfileAccordionSection
|
||||
id="fleet"
|
||||
title={t('profile.sections.fleet')}
|
||||
icon={<Ship size={20} aria-hidden="true" />}
|
||||
defaultOpen
|
||||
forceOpen={fleetSectionTourOpen ? true : undefined}
|
||||
>
|
||||
<VesselPoolForm />
|
||||
<PersonPoolForm />
|
||||
</ProfileAccordionSection>
|
||||
|
||||
<section className="member-editor-card glass">
|
||||
<ProfileAccordionSection
|
||||
id="security"
|
||||
title={t('profile.sections.security')}
|
||||
icon={<Shield size={20} aria-hidden="true" />}
|
||||
>
|
||||
<section className="member-editor-card glass profile-accordion-inner-card">
|
||||
<div className="profile-section-header">
|
||||
<Shield size={20} />
|
||||
<h3>{t('profile.security_title')}</h3>
|
||||
@@ -729,7 +759,14 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="form-card profile-stats-section">
|
||||
</ProfileAccordionSection>
|
||||
|
||||
<ProfileAccordionSection
|
||||
id="stats"
|
||||
title={t('profile.sections.stats')}
|
||||
icon={<BarChart2 size={20} aria-hidden="true" />}
|
||||
>
|
||||
<section className="form-card profile-stats-section profile-accordion-inner-card">
|
||||
<div className="form-header">
|
||||
<BarChart2 size={24} className="form-icon" />
|
||||
<div>
|
||||
@@ -791,8 +828,14 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</ProfileAccordionSection>
|
||||
|
||||
<AccountDangerZone className="mt-6" />
|
||||
<ProfileAccordionSection
|
||||
id="danger"
|
||||
title={t('profile.sections.danger')}
|
||||
>
|
||||
<AccountDangerZone className="profile-accordion-inner-card" />
|
||||
</ProfileAccordionSection>
|
||||
</>
|
||||
) : null}
|
||||
</main>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Compass, Palette, Save, Check, Cloud } from 'lucide-react'
|
||||
import { Compass, Palette, Save, Check, Cloud, Brain } from 'lucide-react'
|
||||
import ThemedSelect from './ThemedSelect.tsx'
|
||||
import PushNotificationSettings from './PushNotificationSettings.tsx'
|
||||
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
getThemePreference,
|
||||
setColorSchemePreference,
|
||||
setOwmApiKey,
|
||||
setThemePreference
|
||||
setThemePreference,
|
||||
getAiAuthorized,
|
||||
setAiAuthorized
|
||||
} from '../services/userPreferences.js'
|
||||
|
||||
interface UserProfilePreferencesProps {
|
||||
@@ -28,6 +30,7 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
|
||||
const [colorScheme, setColorScheme] = useState(() => getColorSchemePreference(userId))
|
||||
const [savingOwm, setSavingOwm] = useState(false)
|
||||
const [owmSaved, setOwmSaved] = useState(false)
|
||||
const [aiAuthorized, setAiAuthorizedState] = useState(() => getAiAuthorized(userId))
|
||||
|
||||
const persistAppearance = (nextTheme: string, nextColorScheme: string) => {
|
||||
setThemePreference(userId, nextTheme)
|
||||
@@ -58,6 +61,12 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
|
||||
window.setTimeout(() => setOwmSaved(false), 3000)
|
||||
}
|
||||
|
||||
const handleAiToggle = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const nextVal = e.target.checked
|
||||
setAiAuthorizedState(nextVal)
|
||||
setAiAuthorized(userId, nextVal)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="member-editor-card glass">
|
||||
@@ -152,6 +161,42 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="member-editor-card glass">
|
||||
<div className="profile-section-header">
|
||||
<Brain size={20} style={{ color: 'var(--app-accent-light)' }} />
|
||||
<h3 style={{ margin: 0, color: 'var(--app-accent-light)', fontSize: '16px' }}>
|
||||
{t('profile.ai_title')}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: '0 0 12px 0' }}>
|
||||
{t('profile.ai_desc')}
|
||||
</p>
|
||||
<p className="text-muted" style={{ fontSize: '13px', lineHeight: '145%', margin: '0 0 16px 0', whiteSpace: 'pre-line' }}>
|
||||
{t('profile.ai_help')}
|
||||
</p>
|
||||
|
||||
<label
|
||||
className="switch-label"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
color: '#f1f5f9'
|
||||
}}
|
||||
>
|
||||
<input
|
||||
id="profile-ai-authorize"
|
||||
type="checkbox"
|
||||
checked={aiAuthorized}
|
||||
onChange={handleAiToggle}
|
||||
style={{ width: '18px', height: '18px', cursor: 'pointer' }}
|
||||
/>
|
||||
<span>{t('profile.ai_enable_label')}</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<PushNotificationSettings />
|
||||
<PwaInstallPrompt variant="inline" />
|
||||
</>
|
||||
|
||||
@@ -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,114 @@
|
||||
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
|
||||
transcribed?: boolean
|
||||
}
|
||||
|
||||
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,9 +17,13 @@ describe('AppTourContext step order', () => {
|
||||
expect(profileIndex).toBeGreaterThan(FULL_STEP_ORDER.indexOf('nav_feedback'))
|
||||
expect(prefsIndex).toBe(profileIndex + 1)
|
||||
expect(finishIndex).toBe(prefsIndex + 1)
|
||||
expect(FULL_STEP_ORDER).toContain('profile_vessel_pool')
|
||||
expect(FULL_STEP_ORDER).toContain('profile_crew_pool')
|
||||
expect(FULL_STEP_ORDER).toContain('nav_logbook_crew')
|
||||
expect(FULL_STEP_ORDER).toHaveLength(13)
|
||||
expect(FULL_STEP_ORDER.indexOf('profile_vessel_pool')).toBeLessThan(
|
||||
FULL_STEP_ORDER.indexOf('profile_crew_pool')
|
||||
)
|
||||
expect(FULL_STEP_ORDER).toHaveLength(14)
|
||||
})
|
||||
|
||||
it('excludes profile, stats and feedback from demo tour', () => {
|
||||
|
||||
@@ -26,6 +26,7 @@ export type TourStepId =
|
||||
| 'entry_open'
|
||||
| 'entry_track'
|
||||
| 'nav_vessel'
|
||||
| 'profile_vessel_pool'
|
||||
| 'profile_crew_pool'
|
||||
| 'nav_logbook_crew'
|
||||
| 'nav_stats'
|
||||
@@ -72,6 +73,7 @@ export const FULL_STEP_ORDER: TourStepId[] = [
|
||||
'entry_open',
|
||||
'entry_track',
|
||||
'nav_vessel',
|
||||
'profile_vessel_pool',
|
||||
'profile_crew_pool',
|
||||
'nav_logbook_crew',
|
||||
'nav_stats',
|
||||
@@ -115,6 +117,7 @@ const TARGET_BY_STEP: Partial<Record<TourStepId, string>> = {
|
||||
entry_open: '[data-tour="entry-first"]',
|
||||
entry_track: '[data-tour="entry-track"]',
|
||||
nav_vessel: '[data-tour="nav-vessel"]',
|
||||
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"]',
|
||||
@@ -131,7 +134,12 @@ export function tourStepOpensEntry(stepId: TourStepId): boolean {
|
||||
export function getTourTargetDelay(stepId: TourStepId): number {
|
||||
if (stepId === 'entry_track') return 400
|
||||
if (stepId === 'nav_feedback') return 180
|
||||
if (stepId === 'nav_profile' || stepId === 'profile_preferences' || stepId === 'profile_crew_pool') {
|
||||
if (
|
||||
stepId === 'nav_profile' ||
|
||||
stepId === 'profile_preferences' ||
|
||||
stepId === 'profile_vessel_pool' ||
|
||||
stepId === 'profile_crew_pool'
|
||||
) {
|
||||
return 250
|
||||
}
|
||||
return 0
|
||||
@@ -189,7 +197,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
||||
nav.setSelectedEntryId(null)
|
||||
nav.setActiveTab('vessel')
|
||||
}
|
||||
if (stepId === 'profile_crew_pool') {
|
||||
if (stepId === 'profile_vessel_pool' || stepId === 'profile_crew_pool') {
|
||||
nav.setSelectedEntryId(null)
|
||||
nav.setLogbookActive(false)
|
||||
nav.setProfileOpen(true)
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useDialog } from '../components/ModalDialog.tsx'
|
||||
|
||||
interface UnsavedChangesContextValue {
|
||||
setDirty: (source: string, dirty: boolean) => void
|
||||
registerSaveHandler: (source: string, handler: (() => Promise<void>) | null) => void
|
||||
confirmLeave: () => Promise<boolean>
|
||||
}
|
||||
|
||||
@@ -19,23 +20,51 @@ const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(n
|
||||
|
||||
export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
|
||||
const { t } = useTranslation()
|
||||
const { showConfirm } = useDialog()
|
||||
const { showConfirmLeave, showAlert } = useDialog()
|
||||
const dirtySources = useRef(new Set<string>())
|
||||
const saveHandlers = useRef(new Map<string, () => Promise<void>>())
|
||||
|
||||
const setDirty = useCallback((source: string, dirty: boolean) => {
|
||||
if (dirty) dirtySources.current.add(source)
|
||||
else dirtySources.current.delete(source)
|
||||
}, [])
|
||||
|
||||
const registerSaveHandler = useCallback((source: string, handler: (() => Promise<void>) | null) => {
|
||||
if (handler) saveHandlers.current.set(source, handler)
|
||||
else saveHandlers.current.delete(source)
|
||||
}, [])
|
||||
|
||||
const confirmLeave = useCallback(async (): Promise<boolean> => {
|
||||
if (dirtySources.current.size === 0) return true
|
||||
return showConfirm(
|
||||
|
||||
const canSave = [...dirtySources.current].some((source) => saveHandlers.current.has(source))
|
||||
const choice = await showConfirmLeave(
|
||||
t('common.unsaved_changes_message'),
|
||||
t('common.unsaved_changes_title'),
|
||||
t('common.unsaved_changes_leave'),
|
||||
t('common.unsaved_changes_stay')
|
||||
t('common.unsaved_changes_stay'),
|
||||
t('common.unsaved_changes_save_leave'),
|
||||
t('common.unsaved_changes_discard'),
|
||||
{ showSave: canSave }
|
||||
)
|
||||
}, [showConfirm, t])
|
||||
|
||||
if (choice === 'stay') return false
|
||||
if (choice === 'discard') return true
|
||||
|
||||
const handlers = [...dirtySources.current]
|
||||
.map((source) => saveHandlers.current.get(source))
|
||||
.filter((handler): handler is () => Promise<void> => handler != null)
|
||||
|
||||
try {
|
||||
for (const handler of handlers) {
|
||||
await handler()
|
||||
}
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('Failed to save before leaving:', err)
|
||||
await showAlert(t('errors.save_failed'))
|
||||
return false
|
||||
}
|
||||
}, [showConfirmLeave, showAlert, t])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
@@ -47,7 +76,10 @@ export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
|
||||
return () => window.removeEventListener('beforeunload', handler)
|
||||
}, [])
|
||||
|
||||
const value = useMemo(() => ({ setDirty, confirmLeave }), [setDirty, confirmLeave])
|
||||
const value = useMemo(
|
||||
() => ({ setDirty, registerSaveHandler, confirmLeave }),
|
||||
[setDirty, registerSaveHandler, confirmLeave]
|
||||
)
|
||||
|
||||
return (
|
||||
<UnsavedChangesContext.Provider value={value}>
|
||||
@@ -65,13 +97,26 @@ export function useUnsavedChangesContext(): UnsavedChangesContextValue {
|
||||
}
|
||||
|
||||
/** Register a form/view as having unsaved changes (cleared automatically on unmount). */
|
||||
export function useRegisterUnsavedChanges(source: string, isDirty: boolean) {
|
||||
const { setDirty, confirmLeave } = useUnsavedChangesContext()
|
||||
export function useRegisterUnsavedChanges(
|
||||
source: string,
|
||||
isDirty: boolean,
|
||||
onSave?: () => Promise<void>
|
||||
) {
|
||||
const { setDirty, registerSaveHandler, confirmLeave } = useUnsavedChangesContext()
|
||||
|
||||
useEffect(() => {
|
||||
setDirty(source, isDirty)
|
||||
return () => setDirty(source, false)
|
||||
}, [source, isDirty, setDirty])
|
||||
|
||||
useEffect(() => {
|
||||
if (!onSave) {
|
||||
registerSaveHandler(source, null)
|
||||
return
|
||||
}
|
||||
registerSaveHandler(source, onSave)
|
||||
return () => registerSaveHandler(source, null)
|
||||
}, [source, onSave, registerSaveHandler])
|
||||
|
||||
return { confirmLeave }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
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) : '',
|
||||
transcribed: decrypted.transcribed !== false
|
||||
})
|
||||
} 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 = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
checkForUpdate()
|
||||
// Delay check on wake-up to allow the mobile network stack to stabilize
|
||||
setTimeout(checkForUpdate, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
const onOnline = () => {
|
||||
checkForUpdate()
|
||||
// Small delay to ensure connection is fully established
|
||||
setTimeout(checkForUpdate, 500)
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
|
||||
+204
-76
@@ -6,6 +6,10 @@
|
||||
"beta": "Beta",
|
||||
"beta_hint": "Betaversion - funktioner kan stadig ændres"
|
||||
},
|
||||
"footer": {
|
||||
"kofi_label": "Ko-fi",
|
||||
"kofi_title": "Støt projektet, videreudvikling og driftsomkostninger på Ko-fi"
|
||||
},
|
||||
"languages": {
|
||||
"de": "Deutsch",
|
||||
"en": "English",
|
||||
@@ -27,17 +31,20 @@
|
||||
"common": {
|
||||
"unsaved_changes_title": "Ikke gemte ændringer",
|
||||
"unsaved_changes_message": "Du har ændringer, der ikke er gemt. Vil du virkelig forlade siden? Dine ændringer vil gå tabt.",
|
||||
"unsaved_changes_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": {
|
||||
"dashboard": "Dashboard",
|
||||
"vessel": "Skibsdata",
|
||||
"crew": "Besætningsliste",
|
||||
"crew": "Crew",
|
||||
"deviation": "Tabel over distraktioner",
|
||||
"logs": "Indlæg i logbogen",
|
||||
"stats": "Statistik",
|
||||
"settings": "Indstillinger"
|
||||
"settings": "Indstillinger",
|
||||
"admin": "Admin"
|
||||
},
|
||||
"auth": {
|
||||
"welcome": "Velkommen til Kapteins Daagbok.",
|
||||
@@ -84,7 +91,15 @@
|
||||
"use_localhost_link": "Skift til localhost",
|
||||
"error_passkey_cancelled": "Passkey-login blev annulleret eller udløb. Prøv igen.",
|
||||
"error_invalid_rp_id": "Passkey-domæne matcher ikke (RP ID). Brug http://localhost:5173 med RP_ID=localhost i .env til lokal udvikling.",
|
||||
"error_session_incomplete": "Login ufuldstændig. Log ind med passkey igen."
|
||||
"error_session_incomplete": "Login ufuldstændig. Log ind med passkey igen.",
|
||||
"restore_checking": "Tjekker session…",
|
||||
"restore_title": "Gendan session",
|
||||
"restore_subtitle": "Du er stadig logget ind. Lås din logbog op med passkey eller PIN.",
|
||||
"restore_unlocking": "Låser op…",
|
||||
"restore_with_passkey": "Lås op med passkey ({{name}})",
|
||||
"restore_with_pin": "Lås op med PIN",
|
||||
"restore_pin_warning": "Indtast din lokale PIN for at låse logbogen op efter genindlæsning.",
|
||||
"restore_other_account": "Log ind med en anden konto"
|
||||
},
|
||||
"pwa": {
|
||||
"title": "Installer app",
|
||||
@@ -203,16 +218,16 @@
|
||||
"sign_badge_skipper_title_valid": "Skipper har udgivet",
|
||||
"sign_badge_skipper_title_invalid": "Skippers signatur er ugyldig - indholdet er blevet ændret",
|
||||
"sign_classic_or_passkey": "Valgfrit: klassisk underskrift eller Passkey-frigivelse ovenfor",
|
||||
"sign_crew_passkey_hint": "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_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_active": "Denne post er underskrevet. Ændringer i logbogen (undtagen fotos) fjerner automatisk skipperens og besætningens underskrifter.",
|
||||
"sign_lock_notice": "Efter underskrivelsen kan der ikke foretages ændringer i logbogen (undtagen fotos), uden at skipper og crew skal skrive under igen.",
|
||||
"sign_lock_active": "Denne post er underskrevet. Ændringer i logbogen (undtagen fotos) fjerner automatisk skipperens og crews underskrifter.",
|
||||
"sign_lock_warning_title": "Bekræft underskrift",
|
||||
"sign_lock_warning": "Efter underskrivelsen er det ikke længere muligt at foretage ændringer i logbogen (undtagen fotos), uden at skipper og 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_cancel": "Annuller",
|
||||
"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!",
|
||||
"back_to_list": "Tilbage til tidsskriftslisten",
|
||||
"save": "Gem logbogsside",
|
||||
@@ -243,13 +258,13 @@
|
||||
"live_sails_confirm": "Indtast",
|
||||
"live_sails_confirm_count": "Indtast ({{count}})",
|
||||
"live_sails": "Sejl: {{sails}}",
|
||||
"live_fix": "Fix",
|
||||
"live_fix_coords": "Fix {{lat}}, {{lng}}",
|
||||
"live_fix_manual_hint": "GPS ikke tilgængelig. Indtast bredde- og længdegrad manuelt, eller prøv igen med GPS-knappen.",
|
||||
"live_fix_gps_loading": "Henter GPS-position…",
|
||||
"live_fix_invalid": "Indtast gyldige koordinater (bredde −90…90, længde −180…180).",
|
||||
"live_fix_lat_placeholder": "Bredde (Lat)",
|
||||
"live_fix_lng_placeholder": "Længde (Lng)",
|
||||
"live_position": "Position",
|
||||
"live_position_coords": "Position {{lat}}, {{lng}}",
|
||||
"live_position_manual_hint": "GPS ikke tilgængelig. Indtast bredde- og længdegrad manuelt, eller prøv igen med GPS-knappen.",
|
||||
"live_position_gps_loading": "Henter GPS-position…",
|
||||
"live_position_invalid": "Indtast gyldige koordinater (bredde −90…90, længde −180…180).",
|
||||
"live_position_lat_placeholder": "Bredde (Lat)",
|
||||
"live_position_lng_placeholder": "Længde (Lng)",
|
||||
"live_photo_btn": "Foto (kamera)",
|
||||
"live_photo_capture_btn": "Tag billede",
|
||||
"live_photo_save_btn": "Gem",
|
||||
@@ -260,10 +275,32 @@
|
||||
"live_photo_camera_starting": "Starter kamera…",
|
||||
"live_photo_camera_denied": "Kameraadgang nægtet eller utilgængelig.",
|
||||
"live_photo_camera_unavailable": "Kamera understøttes ikke i denne browser.",
|
||||
"live_photo_no_camera": "Der er intet kamera tilgængeligt på denne enhed.",
|
||||
"live_photo_error": "Foto kunne ikke gemmes.",
|
||||
"live_photo_entry": "Foto: {{caption}}",
|
||||
"live_photo_entry_plain": "Foto taget",
|
||||
"live_undo_photo_hint": "Foto gemt",
|
||||
"live_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_voice_transcribe_action": "Transkribere",
|
||||
"live_voice_transcribing": "Transkriberer…",
|
||||
"live_voice_transcribe_failed": "Stemmebesked gemt, men transkribering mislykkedes.",
|
||||
"live_undo_voice_hint": "Stemmenotat gemt",
|
||||
"live_comment_btn": "Kommentar",
|
||||
"live_comment_placeholder": "Indtast tekst…",
|
||||
"live_comment_confirm": "Indtast",
|
||||
@@ -273,31 +310,35 @@
|
||||
"live_weather_btn": "Vejr",
|
||||
"live_weather_owm_btn": "Hent OpenWeatherMap-vejr",
|
||||
"live_weather_owm_loading": "Henter vejr…",
|
||||
"live_weather_fix_required": "Log først en GPS-fix (Fix-knap) for at hente OpenWeatherMap-vejr. Positionen må højst være 6 timer gammel.",
|
||||
"live_weather_fix_stale": "Den seneste GPS-fix er ældre end 6 timer. Log en ny fix, før du henter vejr.",
|
||||
"live_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_position_stale": "Den seneste position er ældre end 6 timer. Log en ny position, før du henter vejr.",
|
||||
"live_wind_btn": "Vind",
|
||||
"live_temp_btn": "T °C",
|
||||
"live_pressure_btn": "Lufttryk",
|
||||
"live_precip_btn": "Nedbør",
|
||||
"live_sea_state_btn": "Søgang",
|
||||
"live_visibility_btn": "Sigtbarhed",
|
||||
"live_course_btn": "Kurs",
|
||||
"live_fuel_btn": "Diesel",
|
||||
"live_water_btn": "Vand",
|
||||
"live_fuel_btn": "+ Diesel",
|
||||
"live_water_btn": "+ Vand",
|
||||
"live_wind_entry": "Vind {{value}}",
|
||||
"live_temp_entry": "Temperatur {{temp}} °C",
|
||||
"live_pressure_entry": "Lufttryk {{value}} hPa",
|
||||
"live_precip_entry": "Nedbør {{value}}",
|
||||
"live_sea_state_entry": "Søgang {{value}}",
|
||||
"live_visibility_entry": "Sigtbarhed {{value}}",
|
||||
"live_course_entry": "Kurs {{course}}",
|
||||
"live_fuel_entry": "Diesel +{{liters}} L",
|
||||
"live_water_entry": "Vand +{{liters}} L",
|
||||
"live_auto_position": "Auto-position",
|
||||
"live_undo_hint": "Indtastning gemt",
|
||||
"live_undo_btn": "Fortryd",
|
||||
"live_cancel": "Annuller",
|
||||
"live_pressure_placeholder": "f.eks. 1013",
|
||||
"live_temp_placeholder": "f.eks. 18",
|
||||
"live_precip_placeholder": "f.eks. let regn",
|
||||
"live_sea_state_placeholder": "f.eks. 3",
|
||||
"live_visibility_placeholder": "f.eks. 10 km",
|
||||
"live_course_placeholder": "f.eks. 245",
|
||||
"live_fuel_placeholder": "Optankede liter",
|
||||
"live_water_placeholder": "Optankede liter",
|
||||
@@ -315,6 +356,7 @@
|
||||
"carry_over_tanks_yes": "Tag over",
|
||||
"carry_over_tanks_no": "Start med 0",
|
||||
"event_title": "Kronologisk hændelseslog",
|
||||
"event_creator": "Indtastet af",
|
||||
"no_events": "Der er endnu ikke indtastet nogen begivenheder for denne rejsedag.",
|
||||
"event_time": "Tidspunkt på dagen",
|
||||
"event_mgk": "MgK-kursus",
|
||||
@@ -339,6 +381,12 @@
|
||||
"event_wind_direction": "Vindretning",
|
||||
"event_wind_strength": "Vindstyrke",
|
||||
"event_sea_state": "Havets tilstand",
|
||||
"event_visibility": "Sigtbarhed",
|
||||
"event_visibility_placeholder": "f.eks. 10 km",
|
||||
"weather_slider_unset": "—",
|
||||
"weather_slider_pressure": "{{value}} hPa",
|
||||
"weather_slider_sea_state": "Trin {{value}}",
|
||||
"weather_slider_heel": "{{value}}°",
|
||||
"event_weather": "Vejret",
|
||||
"event_log": "Log (sm)",
|
||||
"event_gps": "GPS-position",
|
||||
@@ -346,7 +394,26 @@
|
||||
"event_location_placeholder": "z. f.eks. Kiel",
|
||||
"event_remarks": "Bemærkninger / hændelser",
|
||||
"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_offline": "OpenWeatherMap kræver internetforbindelse. Du er offline lige nu.",
|
||||
"event_wind_pressure": "Lufttryk (hPa)",
|
||||
"event_heel": "Krængning (°)",
|
||||
"event_sails": "Sejlhåndtering/motor",
|
||||
@@ -360,6 +427,18 @@
|
||||
"share_csv": "CSV andel",
|
||||
"export_pdf": "Download PDF.",
|
||||
"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)",
|
||||
"photo_caption_label": "Fotobeskrivelse/etiket (valgfrit)",
|
||||
"photo_caption_placeholder": "f.eks. at sætte sejl nær indsejlingen til havnen",
|
||||
@@ -387,12 +466,12 @@
|
||||
"track_map_error": "Kortet kunne ikke indlæses.",
|
||||
"exporting": "Eksport...",
|
||||
"share_unsupported": "Deling understøttes ikke på denne enhed. Filen er blevet downloadet i stedet.",
|
||||
"invite_crew": "Inviter besætningen",
|
||||
"invite_crew": "Inviter crewen",
|
||||
"invite_link_copied": "Invitationslink kopieret til udklipsholderen!",
|
||||
"invite_link_desc": "Del dette link med besætningsmedlemmer for at give dem skriveadgang til denne logbog.",
|
||||
"collaborators_list": "Medlemmer / besætning",
|
||||
"invite_link_desc": "Del dette link med Crew-medlemmer for at give dem skriveadgang til denne logbog.",
|
||||
"collaborators_list": "Medlemmer / crew",
|
||||
"revoke": "Fjerne",
|
||||
"revoke_confirm": "Er du sikker på, at du vil tilbagekalde dette besætningsmedlems adgang?",
|
||||
"revoke_confirm": "Er du sikker på, at du vil tilbagekalde dette Crew-medlems adgang?",
|
||||
"invite_role": "Rolle",
|
||||
"invite_expires": "Linket er gyldigt i 48 timer",
|
||||
"nmea_import_title": "Import NMEA log",
|
||||
@@ -438,8 +517,8 @@
|
||||
"nmea_change_engine_stop": "Engine off",
|
||||
"nmea_change_autopilot_on": "Autopilot on",
|
||||
"nmea_change_autopilot_off": "Autopilot off",
|
||||
"nmea_change_gps_lost": "GPS fix lost",
|
||||
"nmea_change_gps_regained": "GPS fix restored",
|
||||
"nmea_change_gps_lost": "GPS-position mistet",
|
||||
"nmea_change_gps_regained": "GPS-position gendannet",
|
||||
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
|
||||
"nmea_change_departure": "Departure / underway",
|
||||
"nmea_change_anchor": "Anchored / stop",
|
||||
@@ -453,7 +532,7 @@
|
||||
"new_logbook_placeholder": "Navn på logbog eller yacht",
|
||||
"logout": "Log ud",
|
||||
"logged_in_as": "Logget ind som {{name}}",
|
||||
"delete_confirm": "Er du sikker på, at du vil slette denne logbog permanent? Alle lokale data og serverkopier vil blive destrueret.\n\nTip: Lav en sikkerhedskopi (.daagbok.json) på forhånd under Indstillinger → Sikkerhedskopiering og gendannelse, hvis du vil beholde dataene senere.",
|
||||
"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!",
|
||||
"loading": "Logbøgerne er fyldt op...",
|
||||
"status_synced": "Synkroniseret",
|
||||
@@ -461,11 +540,11 @@
|
||||
"delete_btn": "Slet logbog",
|
||||
"section_owned": "Mine 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_hint": "Du er ejer og skipper af denne logbog",
|
||||
"role_crew": "Adgang for besætning",
|
||||
"role_crew_hint": "Inviteret logbog - du kan arbejde som besætning og underskrive den",
|
||||
"role_crew": "Adgang for crew",
|
||||
"role_crew_hint": "Inviteret logbog - du kan arbejde som crew og underskrive den",
|
||||
"role_read": "Læs kun",
|
||||
"role_read_hint": "Opdelt logbog - kun visning, ingen redigering",
|
||||
"open_profile": "Åben profil af {{name}}",
|
||||
@@ -475,7 +554,7 @@
|
||||
"edit_success": "Logbog omdøbt med succes",
|
||||
"edit_btn": "Omdøb",
|
||||
"filter_label": "Filtrer logbøger",
|
||||
"filter_placeholder": "Navn, årstal eller dato ...",
|
||||
"filter_placeholder": "Navn, årstal, dato, crew eller skib …",
|
||||
"filter_clear": "Nulstil filter",
|
||||
"filter_results": "{{count}} Hits",
|
||||
"filter_no_results": "Ingen logbøger matcher din søgning. Prøv med et andet navn eller et andet år.",
|
||||
@@ -593,6 +672,12 @@
|
||||
"integrations_title": "Integrationer",
|
||||
"owm_key": "OpenWeatherMap API-nøgle",
|
||||
"owm_help": "Valgfrit: egen OpenWeatherMap API-nøgle. Hvis der ikke er nogen indtastning, bruges nøglen på serversiden fra operatørkonfigurationen.",
|
||||
"ai_title": "AI-funktioner og privatliv",
|
||||
"ai_desc": "Autoriser integrationer af kunstig intelligens for dine logbøger.",
|
||||
"ai_help": "Aktivering af AI-funktioner giver appen mulighed for at opsummere dine rejsedage og transkribere optagede stemmememoer. For at behandle disse anmodninger sendes rå stemmedata og rejselogfiler sikkert løbende til OpenRouter. Der gemmes ingen data permanent af AI-modellen.\n\nDisse cloud-ressourcer koster penge at køre. Hvis du kan lide at bruge dem, bedes du overveje at støtte projektet frivilligt med en donation via Ko-fi-linket i footeren for at holde dem gratis og bæredygtige for alle.",
|
||||
"ai_enable_label": "Aktiver transkribering og resuméer af rejsedage",
|
||||
"ai_unauthorized_alert_title": "AI-funktioner er ikke autoriseret",
|
||||
"ai_unauthorized_alert_desc": "For at bruge transkribering eller rejsedagsresuméer skal du autorisere dataoverførslen til OpenRouter i din brugerprofil under 'AI-funktioner og privatliv'.",
|
||||
"prefs_save": "Gemme",
|
||||
"prefs_saving": "Vil blive reddet...",
|
||||
"prefs_saved": "Gemt",
|
||||
@@ -600,58 +685,88 @@
|
||||
"tour_desc": "Lad dig guide gennem de vigtigste områder i appen igen.",
|
||||
"tour_restart": "Start turen igen",
|
||||
"push_title": "Push-meddelelser",
|
||||
"push_desc": "Som logbogsejer får du besked, når inviterede besætningsmedlemmer synkroniserer ændringer. Intet indhold overføres i ren tekst.",
|
||||
"push_enable": "Giv os besked om ændringer i besætningen",
|
||||
"push_desc": "Som logbogsejer får du besked, når inviterede Crew-medlemmer synkroniserer ændringer. Intet indhold overføres i ren tekst.",
|
||||
"push_enable": "Giv os besked om ændringer i crewen",
|
||||
"push_active": "Push-meddelelser er aktive på denne enhed.",
|
||||
"push_unsupported": "Push-meddelelser understøttes ikke i denne browser.",
|
||||
"push_denied_hint": "Notifikationer er blokeret. Tillad dem i browserens eller enhedens indstillinger.",
|
||||
"push_ios_install_hint": "På iPhone/iPad: Føj app til startskærmen (iOS 16.4+) for at bruge push.",
|
||||
"push_error": "Push-meddelelser kunne ikke aktiveres."
|
||||
"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": "Stambesætning og skippere",
|
||||
"subtitle": "Administrer din personpulje her – skippere og besætning til alle logbøger. Vælg aktiv besætning per logbog og rejsedag fra puljen.",
|
||||
"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": "Stambesætning",
|
||||
"crew_section": "Stamm-Crew",
|
||||
"add_skipper": "Tilføj skipper",
|
||||
"add_crew": "Tilføj besætningsmedlem",
|
||||
"add_crew": "Tilføj Crew-medlem",
|
||||
"edit_skipper": "Rediger skipper",
|
||||
"no_skippers": "Ingen skipper i puljen endnu.",
|
||||
"no_crew": "Ingen besætningsmedlemmer i puljen endnu.",
|
||||
"no_crew": "Ingen Crew-medlemmer i puljen endnu.",
|
||||
"delete_confirm": "Fjern denne person fra puljen?"
|
||||
},
|
||||
"logbook_crew": {
|
||||
"title": "Besætning for denne logbog",
|
||||
"subtitle": "Vælg skipper og besætning for denne logbog. Nye rejsedage arver valget som standard.",
|
||||
"loading": "Indlæser besætning…",
|
||||
"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": "Besætning 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 besætning 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 besætning",
|
||||
"saved": "Logbogbesætning gemt.",
|
||||
"selection_only_hint": "Du ser den besætning ejeren har valgt (delt logbog)."
|
||||
"save": "Gem crew",
|
||||
"saved": "Logbog-Crew gemt.",
|
||||
"selection_only_hint": "Du ser den crew ejeren har valgt (delt logbog)."
|
||||
},
|
||||
"entry_crew": {
|
||||
"title": "Besætning på denne rejsedag",
|
||||
"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": "Besætning denne dag",
|
||||
"day_crew": "Crew denne dag",
|
||||
"no_skipper": "Ingen skipper valgt",
|
||||
"no_crew": "Ingen besætning valgt"
|
||||
"no_crew": "Ingen crew valgt"
|
||||
},
|
||||
"crew": {
|
||||
"title": "Skipper- og besætningsprofiler",
|
||||
"title": "Skipper- og Crew-profiler",
|
||||
"skipper_section": "Skipper-profil",
|
||||
"skipper_read_only_hint": "Skipperprofilen kan kun redigeres af logbogens ejer.",
|
||||
"crew_section": "Besætningsliste",
|
||||
"add_crew": "Tilføj besætningsmedlem",
|
||||
"edit_crew": "Rediger besætningsmedlem",
|
||||
"no_crew": "Ingen besætningsmedlemmer tilføjet endnu.",
|
||||
"max_crew": "Det maksimale antal på 12 besætningsmedlemmer i puljen er nået.",
|
||||
"crew_section": "Crew-liste",
|
||||
"add_crew": "Tilføj Crew-medlem",
|
||||
"edit_crew": "Rediger Crew-medlem",
|
||||
"no_crew": "Ingen Crew-medlemmer tilføjet endnu.",
|
||||
"max_crew": "Det maksimale antal på 12 Crew-medlemmer i puljen er nået.",
|
||||
"name": "Navn",
|
||||
"address": "adresse",
|
||||
"birthdate": "Fødselsdag",
|
||||
@@ -664,8 +779,8 @@
|
||||
"save": "Gem skipper-data",
|
||||
"save_member": "Gem medlem",
|
||||
"saved": "Skipperprofilen er blevet gemt!",
|
||||
"loading": "Besætningsfilerne er indlæst.",
|
||||
"delete_confirm": "Er du sikker på, at du vil fjerne dette besætningsmedlem?"
|
||||
"loading": "Crew-filerne er indlæst.",
|
||||
"delete_confirm": "Er du sikker på, at du vil fjerne dette Crew-medlem?"
|
||||
},
|
||||
"deviation": {
|
||||
"title": "Tabel over kompasafvigelser",
|
||||
@@ -684,10 +799,13 @@
|
||||
"no_key": "Ingen OpenWeatherMap API-nøgle tilgængelig. Gem din egen nøgle i brugerprofilen, eller kontakt operatøren.",
|
||||
"weather_success": "Vejrdata hentet med succes!",
|
||||
"weather_error": "Hentning af vejrdata mislykkedes. Tjek API-nøglen og forbindelsen.",
|
||||
"weather_unauthorized": "Hentning af vejrdata mislykkedes. API-nøglen er ugyldig eller ikke autoriseret.",
|
||||
"weather_not_found": "Hentning af vejrdata mislykkedes. Den angivne placering eller koordinater blev ikke fundet.",
|
||||
"weather_bad_request": "Hentning af vejrdata mislykkedes. Ingen placering eller GPS-position blev angivet.",
|
||||
"weather_date_mismatch": "Vejrdata kan kun hentes for i dag ({{today}}). Dette logbogsindlæg er dateret {{date}}.",
|
||||
"gps_error": "Indtast en placering, eller find GPS-koordinaterne.",
|
||||
"share_title": "Del logbog (skrivebeskyttet)",
|
||||
"share_desc": "Aktivér denne mulighed for at oprette et offentligt, skrivebeskyttet link. Alle med linket kan se dine rejser, yachtprofiler og 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_enable": "Aktivér offentligt link",
|
||||
"share_copied": "Link kopieret!",
|
||||
@@ -695,25 +813,25 @@
|
||||
"link_qr_hint": "Scan QR-koden med din telefon",
|
||||
"link_qr_alt": "QR-kode til linket",
|
||||
"danger_zone_title": "Farezone",
|
||||
"danger_zone_desc": "Når du sletter din konto, slettes alle dine Passkey'er, logbøger, skibsdata, besætningsprofiler, rejseindlæg og E2E-nøgler uigenkaldeligt. Denne handling kan ikke fortrydes.",
|
||||
"danger_zone_desc": "Når du sletter din konto, slettes alle dine Passkey'er, logbøger, skibsdata, Crew-profiler, rejseindlæg og E2E-nøgler uigenkaldeligt. Denne handling kan ikke fortrydes.",
|
||||
"delete_account_btn": "Slet konto uigenkaldeligt",
|
||||
"delete_account_confirm_title": "Slette konto?",
|
||||
"delete_account_confirm_desc": "Er du helt sikker på, at du vil slette din konto uigenkaldeligt og alle tilknyttede logbøger og E2E-krypterede data?",
|
||||
"delete_account_confirm_yes": "Ja, slet konto og alle data",
|
||||
"delete_account_confirm_no": "Annuller",
|
||||
"delete_account_failed": "Kontoen kunne ikke slettes. Prøv venligst igen.",
|
||||
"delete_backup_hint": "Tip: Lav sikkerhedskopier af dine logbøger (.daagbok.json) i indstillingerne for hver logbog, før du sletter dem.",
|
||||
"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...",
|
||||
"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_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_message": "Så snart inviterede Crew-medlemmer synkroniserer ændringer, kan du blive informeret via push. Intet logbogsindhold sendes i almindelig tekst.",
|
||||
"invite_push_prompt_ios_message": "Så snart Crew-medlemmerne synkroniserer ændringer, kan du blive informeret via push. På iPhone/iPad: Føj appen til startskærmen (iOS 16.4+), og aktiver derefter push i brugerprofilen.",
|
||||
"invite_push_prompt_enable": "Aktiver nu",
|
||||
"invite_push_prompt_later": "Senere",
|
||||
"invite_push_prompt_success": "Push-meddelelser er aktive på denne enhed.",
|
||||
"backup_title": "Sikkerhedskopiering og gendannelse",
|
||||
"backup_desc": "Komplet krypteret backup af denne logbog (poster, fotos, GPS-spor, 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_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_desc": "Gendanner en sikkerhedskopi til din nuværende konto - selv efter registrering af en ny konto.",
|
||||
"backup_passphrase": "Backup-passphrase",
|
||||
@@ -725,7 +843,13 @@
|
||||
"backup_export_btn": "Download backup",
|
||||
"backup_exporting": "Sikkerhedskopien er oprettet...",
|
||||
"backup_export_success": "Backup oprettet ({{count}} rejsedage).",
|
||||
"backup_file_label": "Backup-fil (.daagbok.json)",
|
||||
"backup_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_previewing": "Tjek...",
|
||||
"backup_restore_btn": "Gendan",
|
||||
@@ -741,7 +865,7 @@
|
||||
"backup_new_id_confirm": "Importere backup'en som en ny logbog med et nyt ID?",
|
||||
"backup_stat_entries": "{{count}} Rejsedage",
|
||||
"backup_stat_photos": "{{count}} Fotos",
|
||||
"backup_stat_crew": "{{count}} Besætningens poster",
|
||||
"backup_stat_crew": "{{count}} Crew-poster",
|
||||
"backup_stat_tracks": "{{count}} GPS-spor",
|
||||
"backup_exported_at": "Eksporteret: {{date}}"
|
||||
},
|
||||
@@ -883,7 +1007,7 @@
|
||||
},
|
||||
"welcome_public": {
|
||||
"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": {
|
||||
"title": "Indlæg i logbogen",
|
||||
@@ -902,16 +1026,20 @@
|
||||
"body": "Upload GPX-filer, eller se allerede gemte ruter på kortet - inklusive afstand og hastighed."
|
||||
},
|
||||
"nav_vessel": {
|
||||
"title": "Skibsdata",
|
||||
"body": "Indtast navn, dimensioner og tekniske data for din yacht - udfyld én gang, tilgængelig for alle rejsedage."
|
||||
"title": "Skib for logbog",
|
||||
"body": "Vælg skib fra flåden for denne logbog. Administrer skibe i brugerprofilen under Flåde og besætning."
|
||||
},
|
||||
"profile_vessel_pool": {
|
||||
"title": "Skibsflåde",
|
||||
"body": "I brugerprofilen opretter du alle dine skibe – charter, eget skib osv. Vælg derefter det rigtige skib per logbog."
|
||||
},
|
||||
"profile_crew_pool": {
|
||||
"title": "Stambesætning og skippere",
|
||||
"body": "I brugerprofilen vedligeholder du en personpulje – flere skippere (f.eks. charter) og besætning til alle logbøger."
|
||||
"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": "Besætning per logbog",
|
||||
"body": "Vælg skipper og besætning fra puljen til denne logbog. Rejsedage arver valget som standard."
|
||||
"title": "Crew per logbog",
|
||||
"body": "Vælg skipper og crew fra puljen til denne logbog. Rejsedage arver valget som standard."
|
||||
},
|
||||
"nav_stats": {
|
||||
"title": "Statistik-dashboard",
|
||||
@@ -937,7 +1065,7 @@
|
||||
},
|
||||
"seo": {
|
||||
"title": "Kapteins Daagbok - Gratis digital yachtlogbog (reklamefri)",
|
||||
"description": "Gratis, reklamefri digital yachtlogbog med end-to-end-kryptering og Passkey-login. Dokumenter sikkert rejsedage, GPS-spor, 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",
|
||||
"ogImageAlt": "Kapteins Daagbok Logo"
|
||||
}
|
||||
|
||||
+165
-37
@@ -6,6 +6,10 @@
|
||||
"beta": "Beta",
|
||||
"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": {
|
||||
"de": "Deutsch",
|
||||
"en": "English",
|
||||
@@ -27,17 +31,20 @@
|
||||
"common": {
|
||||
"unsaved_changes_title": "Ungespeicherte Änderungen",
|
||||
"unsaved_changes_message": "Du hast ungespeicherte Änderungen. Möchtest du die Seite wirklich verlassen? Deine Änderungen gehen verloren.",
|
||||
"unsaved_changes_leave": "Verlassen",
|
||||
"unsaved_changes_stay": "Bleiben"
|
||||
"unsaved_changes_stay": "Bleiben",
|
||||
"unsaved_changes_save_leave": "Speichern & verlassen",
|
||||
"unsaved_changes_discard": "Verwerfen",
|
||||
"unsaved_changes_leave": "Verlassen"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"vessel": "Schiffsdaten",
|
||||
"crew": "Mannschaft",
|
||||
"crew": "Crew",
|
||||
"deviation": "Ablenkungstabelle",
|
||||
"logs": "Logbucheinträge",
|
||||
"stats": "Statistik",
|
||||
"settings": "Einstellungen"
|
||||
"settings": "Einstellungen",
|
||||
"admin": "Admin"
|
||||
},
|
||||
"auth": {
|
||||
"welcome": "Willkommen bei Kapteins Daagbok",
|
||||
@@ -84,7 +91,15 @@
|
||||
"use_localhost_link": "Zu localhost wechseln",
|
||||
"error_passkey_cancelled": "Passkey-Anmeldung abgebrochen oder abgelaufen. Bitte erneut versuchen.",
|
||||
"error_invalid_rp_id": "Passkey-Domain passt nicht (RP ID). Lokal nur http://localhost:5173 mit RP_ID=localhost in .env verwenden.",
|
||||
"error_session_incomplete": "Anmeldung unvollständig. Bitte erneut mit Passkey anmelden."
|
||||
"error_session_incomplete": "Anmeldung unvollständig. Bitte erneut mit Passkey anmelden.",
|
||||
"restore_checking": "Session wird geprüft…",
|
||||
"restore_title": "Session wiederherstellen",
|
||||
"restore_subtitle": "Deine Anmeldung ist noch aktiv. Entsperre dein Logbuch mit Passkey oder PIN.",
|
||||
"restore_unlocking": "Wird entsperrt…",
|
||||
"restore_with_passkey": "Mit Passkey entsperren ({{name}})",
|
||||
"restore_with_pin": "Mit PIN entsperren",
|
||||
"restore_pin_warning": "Gib deine lokale PIN ein, um dein Logbuch nach dem Neuladen zu entsperren.",
|
||||
"restore_other_account": "Anderer Account anmelden"
|
||||
},
|
||||
"pwa": {
|
||||
"title": "App installieren",
|
||||
@@ -243,13 +258,13 @@
|
||||
"live_sails_confirm": "Eintragen",
|
||||
"live_sails_confirm_count": "Eintragen ({{count}})",
|
||||
"live_sails": "Segel: {{sails}}",
|
||||
"live_fix": "Fix",
|
||||
"live_fix_coords": "Fix {{lat}}, {{lng}}",
|
||||
"live_fix_manual_hint": "GPS nicht verfügbar. Breiten- und Längengrad manuell eingeben oder erneut per GPS-Knopf versuchen.",
|
||||
"live_fix_gps_loading": "GPS-Position wird ermittelt…",
|
||||
"live_fix_invalid": "Bitte gültige Koordinaten eingeben (Breite −90…90, Länge −180…180).",
|
||||
"live_fix_lat_placeholder": "Breite (Lat)",
|
||||
"live_fix_lng_placeholder": "Länge (Lng)",
|
||||
"live_position": "Position",
|
||||
"live_position_coords": "Position {{lat}}, {{lng}}",
|
||||
"live_position_manual_hint": "GPS nicht verfügbar. Breiten- und Längengrad manuell eingeben oder erneut per GPS-Knopf versuchen.",
|
||||
"live_position_gps_loading": "GPS-Position wird ermittelt…",
|
||||
"live_position_invalid": "Bitte gültige Koordinaten eingeben (Breite −90…90, Länge −180…180).",
|
||||
"live_position_lat_placeholder": "Breite (Lat)",
|
||||
"live_position_lng_placeholder": "Länge (Lng)",
|
||||
"live_photo_btn": "Foto (Kamera)",
|
||||
"live_photo_capture_btn": "Aufnehmen",
|
||||
"live_photo_save_btn": "Speichern",
|
||||
@@ -260,44 +275,70 @@
|
||||
"live_photo_camera_starting": "Kamera wird gestartet…",
|
||||
"live_photo_camera_denied": "Kamerazugriff verweigert oder nicht verfügbar.",
|
||||
"live_photo_camera_unavailable": "Kamera wird von diesem Browser nicht unterstützt.",
|
||||
"live_photo_no_camera": "Auf diesem Gerät ist keine Kamera verfügbar.",
|
||||
"live_photo_error": "Foto konnte nicht gespeichert werden.",
|
||||
"live_photo_entry": "Foto: {{caption}}",
|
||||
"live_photo_entry_plain": "Foto aufgenommen",
|
||||
"live_undo_photo_hint": "Foto gespeichert",
|
||||
"live_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_voice_transcribe_action": "Transkribieren",
|
||||
"live_voice_transcribing": "Transkribiere...",
|
||||
"live_voice_transcribe_failed": "Sprachmemo gespeichert, aber Transkription fehlgeschlagen.",
|
||||
"live_undo_voice_hint": "Sprachnotiz gespeichert",
|
||||
"live_comment_btn": "Kommentar",
|
||||
"live_comment_placeholder": "Freitext eingeben…",
|
||||
"live_comment_confirm": "Eintragen",
|
||||
"live_gps_error": "GPS-Position konnte nicht ermittelt werden.",
|
||||
"live_gps_start_hint": "Beginne deine Tagesreise immer mit einem Standort.",
|
||||
"live_gps_start_hint": "Beginne deine Tagesreise immer mit einer Position.",
|
||||
"live_event_generic": "Ereignis",
|
||||
"live_weather_btn": "Wetter",
|
||||
"live_weather_owm_btn": "OpenWeatherMap Wetter abrufen",
|
||||
"live_weather_owm_loading": "Wetter wird geladen…",
|
||||
"live_weather_fix_required": "Für Wetter von OpenWeatherMap zuerst einen GPS-Fix eintragen (Schaltfläche „Fix“). Die Position darf höchstens 6 Stunden alt sein.",
|
||||
"live_weather_fix_stale": "Der letzte GPS-Fix ist älter als 6 Stunden. Bitte erneut einen Fix loggen, bevor du Wetter abrufst.",
|
||||
"live_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_position_stale": "Die letzte Position ist älter als 6 Stunden. Bitte erneut eine Position loggen, bevor du Wetter abrufst.",
|
||||
"live_wind_btn": "Wind",
|
||||
"live_temp_btn": "T °C",
|
||||
"live_pressure_btn": "Luftdruck",
|
||||
"live_precip_btn": "Niederschlag",
|
||||
"live_sea_state_btn": "Seegang",
|
||||
"live_visibility_btn": "Sichtweite",
|
||||
"live_course_btn": "Kurs",
|
||||
"live_fuel_btn": "Diesel",
|
||||
"live_water_btn": "Wasser",
|
||||
"live_fuel_btn": "+ Diesel",
|
||||
"live_water_btn": "+ Wasser",
|
||||
"live_wind_entry": "Wind {{value}}",
|
||||
"live_temp_entry": "Temperatur {{temp}} °C",
|
||||
"live_pressure_entry": "Luftdruck {{value}} hPa",
|
||||
"live_precip_entry": "Niederschlag {{value}}",
|
||||
"live_sea_state_entry": "Seegang {{value}}",
|
||||
"live_visibility_entry": "Sichtweite {{value}}",
|
||||
"live_course_entry": "Kurs {{course}}",
|
||||
"live_fuel_entry": "Diesel +{{liters}} L",
|
||||
"live_water_entry": "Wasser +{{liters}} L",
|
||||
"live_auto_position": "Auto-Position",
|
||||
"live_undo_hint": "Eintrag gespeichert",
|
||||
"live_undo_btn": "Rückgängig",
|
||||
"live_cancel": "Abbruch",
|
||||
"live_pressure_placeholder": "z. B. 1013",
|
||||
"live_temp_placeholder": "z. B. 18",
|
||||
"live_precip_placeholder": "z. B. leichter Regen",
|
||||
"live_sea_state_placeholder": "z. B. 3",
|
||||
"live_visibility_placeholder": "z. B. 10 km",
|
||||
"live_course_placeholder": "z. B. 245",
|
||||
"live_fuel_placeholder": "Nachgefüllte Liter",
|
||||
"live_water_placeholder": "Nachgefüllte Liter",
|
||||
@@ -315,6 +356,7 @@
|
||||
"carry_over_tanks_yes": "Übernehmen",
|
||||
"carry_over_tanks_no": "Mit 0 starten",
|
||||
"event_title": "Chronologisches Ereignisprotokoll",
|
||||
"event_creator": "Eingetragen von",
|
||||
"no_events": "Noch keine Ereignisse für diesen Reisetag eingetragen.",
|
||||
"event_time": "Uhrzeit",
|
||||
"event_mgk": "MgK Kurs",
|
||||
@@ -339,6 +381,12 @@
|
||||
"event_wind_direction": "Wind-Richtung",
|
||||
"event_wind_strength": "Windstärke",
|
||||
"event_sea_state": "Seegang",
|
||||
"event_visibility": "Sichtweite",
|
||||
"event_visibility_placeholder": "z. B. 10 km",
|
||||
"weather_slider_unset": "—",
|
||||
"weather_slider_pressure": "{{value}} hPa",
|
||||
"weather_slider_sea_state": "Stufe {{value}}",
|
||||
"weather_slider_heel": "{{value}}°",
|
||||
"event_weather": "Wetter",
|
||||
"event_log": "Logge (sm)",
|
||||
"event_gps": "GPS-Position",
|
||||
@@ -346,7 +394,26 @@
|
||||
"event_location_placeholder": "z. B. Kiel",
|
||||
"event_remarks": "Bemerkungen / Vorkommnisse",
|
||||
"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_offline": "OpenWeatherMap erfordert eine Internetverbindung. Du bist derzeit offline.",
|
||||
"event_wind_pressure": "Luftdruck (hPa)",
|
||||
"event_heel": "Krängung (°)",
|
||||
"event_sails": "Segelführung / Motor",
|
||||
@@ -360,6 +427,18 @@
|
||||
"share_csv": "CSV teilen",
|
||||
"export_pdf": "PDF herunterladen",
|
||||
"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)",
|
||||
"photo_caption_label": "Foto-Beschreibung / Label (Optional)",
|
||||
"photo_caption_placeholder": "z.B. Segel setzen nahe Hafeneinfahrt",
|
||||
@@ -428,8 +507,8 @@
|
||||
"nmea_change_engine_stop": "Motor aus",
|
||||
"nmea_change_autopilot_on": "Autopilot ein",
|
||||
"nmea_change_autopilot_off": "Autopilot aus",
|
||||
"nmea_change_gps_lost": "GPS-Fix verloren",
|
||||
"nmea_change_gps_regained": "GPS-Fix wiederhergestellt",
|
||||
"nmea_change_gps_lost": "GPS-Position verloren",
|
||||
"nmea_change_gps_regained": "GPS-Position wiederhergestellt",
|
||||
"nmea_change_water_temp": "Wassertemp. {{from}} → {{to}} °C",
|
||||
"nmea_change_departure": "Abfahrt / Fahrtbeginn",
|
||||
"nmea_change_anchor": "Ankern / Stop",
|
||||
@@ -453,7 +532,7 @@
|
||||
"new_logbook_placeholder": "Name des Logbuchs oder der Yacht",
|
||||
"logout": "Abmelden",
|
||||
"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!",
|
||||
"loading": "Logbücher werden geladen...",
|
||||
"status_synced": "Synchronisiert",
|
||||
@@ -475,7 +554,7 @@
|
||||
"edit_success": "Logbuch erfolgreich umbenannt",
|
||||
"edit_btn": "Umbenennen",
|
||||
"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_results": "{{count}} Treffer",
|
||||
"filter_no_results": "Keine Logbücher passen zu deiner Suche. Probiere einen anderen Namen oder ein anderes Jahr.",
|
||||
@@ -593,6 +672,12 @@
|
||||
"integrations_title": "Integrationen",
|
||||
"owm_key": "OpenWeatherMap API-Schlüssel",
|
||||
"owm_help": "Optional: eigener OpenWeatherMap-API-Schlüssel. Ohne Eintrag wird der serverseitige Schlüssel aus der Betreiber-Konfiguration verwendet.",
|
||||
"ai_title": "KI-Funktionen & Datenschutz",
|
||||
"ai_desc": "Autorisiere die Nutzung von künstlicher Intelligenz (lokale/Cloud-Integrationen) für deine Logbücher.",
|
||||
"ai_help": "Die Aktivierung ermöglicht es, Reiseberichte automatisch zusammenzufassen und Sprachnotizen zu transkribieren. Zur Verarbeitung werden Sprachaufnahmen und Logbucheinträge verschlüsselt an OpenRouter übertragen. Die Daten werden dort nicht dauerhaft gespeichert.\n\nDa der Betrieb dieser Cloud-Ressourcen Kosten verursacht, freuen wir uns über eine freiwillige Unterstützung über den Ko-fi-Spenden-Link im Footer, um diese Funktionen dauerhaft für alle kostenlos anbieten zu können.",
|
||||
"ai_enable_label": "Transkribierung und Tageszusammenfassungen aktivieren",
|
||||
"ai_unauthorized_alert_title": "KI-Funktionen nicht autorisiert",
|
||||
"ai_unauthorized_alert_desc": "Um Sprachnotizen zu transkribieren oder Reiseberichte zusammenzufassen, musst du der Datenübermittlung an OpenRouter in deinem Benutzerprofil unter 'KI-Funktionen & Datenschutz' zustimmen.",
|
||||
"prefs_save": "Speichern",
|
||||
"prefs_saving": "Wird gespeichert…",
|
||||
"prefs_saved": "Gespeichert",
|
||||
@@ -606,11 +691,41 @@
|
||||
"push_unsupported": "Push-Benachrichtigungen werden in diesem Browser nicht unterstützt.",
|
||||
"push_denied_hint": "Benachrichtigungen sind blockiert. Erlaube sie in den Browser- oder Geräteeinstellungen.",
|
||||
"push_ios_install_hint": "Auf dem iPhone/iPad: App zum Home-Bildschirm hinzufügen (iOS 16.4+), um Push zu nutzen.",
|
||||
"push_error": "Push-Benachrichtigungen konnten nicht aktiviert werden."
|
||||
"push_error": "Push-Benachrichtigungen konnten nicht aktiviert werden.",
|
||||
"sections": {
|
||||
"account": "Konto & Einstellungen",
|
||||
"fleet": "Flotte & Crew",
|
||||
"security": "Sicherheit & Gerät",
|
||||
"stats": "Statistik",
|
||||
"danger": "Gefahrenzone"
|
||||
}
|
||||
},
|
||||
"vessel_pool": {
|
||||
"title": "Schiffsflotte",
|
||||
"section_title": "Deine Schiffe",
|
||||
"subtitle": "Pflege hier alle Schiffe für deine Logbücher. Pro Logbuch wählst du das aktive Schiff aus dieser Liste.",
|
||||
"loading": "Schiffsflotte wird geladen…",
|
||||
"add_vessel": "Schiff hinzufügen",
|
||||
"edit_vessel": "Schiff bearbeiten",
|
||||
"no_vessels": "Noch keine Schiffe im Pool.",
|
||||
"delete_confirm": "Dieses Schiff wirklich aus der Flotte entfernen?",
|
||||
"max_vessels": "Maximale Anzahl von 20 Schiffen im Pool erreicht."
|
||||
},
|
||||
"logbook_vessel": {
|
||||
"title": "Schiff für dieses Logbuch",
|
||||
"subtitle": "Wähle das Schiff für dieses Logbuch. Reisetage nutzen Segel- und Tankdaten des gewählten Schiffs.",
|
||||
"active_vessel": "Schiff für dieses Logbuch",
|
||||
"no_vessels_in_pool": "Kein Schiff in der Flotte – zuerst im Benutzerprofil anlegen.",
|
||||
"no_vessel": "Kein Schiff gewählt",
|
||||
"unnamed": "Unbenannt",
|
||||
"save": "Schiff speichern",
|
||||
"saved": "Schiff für das Logbuch gespeichert.",
|
||||
"selection_only_hint": "Du siehst das vom Eigner gewählte Schiff (geteiltes Logbuch).",
|
||||
"manage_in_profile": "Schiffe im Benutzerprofil verwalten"
|
||||
},
|
||||
"person_pool": {
|
||||
"title": "Stammcrew & Skipper",
|
||||
"subtitle": "Lege hier deinen Personen-Pool an – Skipper und Crew für alle Logbücher. Aus diesem Pool wählst du pro Logbuch und Reisetag die aktive Mannschaft.",
|
||||
"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",
|
||||
@@ -622,21 +737,21 @@
|
||||
"delete_confirm": "Diese Person wirklich aus dem Pool entfernen?"
|
||||
},
|
||||
"logbook_crew": {
|
||||
"title": "Mannschaft für dieses Logbuch",
|
||||
"title": "Crew für dieses Logbuch",
|
||||
"subtitle": "Wähle Skipper und Crew für dieses Logbuch. Neue Reisetage übernehmen diese Auswahl standardmäßig.",
|
||||
"loading": "Mannschaft wird geladen…",
|
||||
"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": "Mannschaft speichern",
|
||||
"saved": "Mannschaft für das Logbuch gespeichert.",
|
||||
"selection_only_hint": "Du siehst die vom Eigner festgelegte Mannschaft (geteiltes Logbuch)."
|
||||
"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": "Mannschaft an diesem Reisetag",
|
||||
"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",
|
||||
@@ -684,6 +799,9 @@
|
||||
"no_key": "Kein OpenWeatherMap-API-Schlüssel verfügbar. Hinterlege einen eigenen Schlüssel im Benutzerprofil oder kontaktiere den Betreiber.",
|
||||
"weather_success": "Wetterdaten erfolgreich abgerufen!",
|
||||
"weather_error": "Wetterdatenabruf fehlgeschlagen. Überprüfe den API-Schlüssel und die Verbindung.",
|
||||
"weather_unauthorized": "Wetterdatenabruf fehlgeschlagen. Der API-Schlüssel ist ungültig oder nicht autorisiert.",
|
||||
"weather_not_found": "Wetterdatenabruf fehlgeschlagen. Der angegebene Ort oder die Koordinaten wurden nicht gefunden.",
|
||||
"weather_bad_request": "Wetterdatenabruf fehlgeschlagen. Es wurde kein Ort und keine GPS-Position angegeben.",
|
||||
"weather_date_mismatch": "Wetterdaten können nur für den heutigen Tag ({{today}}) abgerufen werden. Dieser Logbucheintrag ist auf den {{date}} datiert.",
|
||||
"gps_error": "Bitte gib einen Ort an oder ermittle die GPS-Koordinaten.",
|
||||
"share_title": "Logbuch teilen (Schreibgeschützt)",
|
||||
@@ -702,7 +820,7 @@
|
||||
"delete_account_confirm_yes": "Ja, Konto und alle Daten löschen",
|
||||
"delete_account_confirm_no": "Abbrechen",
|
||||
"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…",
|
||||
"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.",
|
||||
@@ -711,9 +829,9 @@
|
||||
"invite_push_prompt_later": "Später",
|
||||
"invite_push_prompt_success": "Push-Benachrichtigungen sind auf diesem Gerät aktiv.",
|
||||
"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_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_desc": "Stellt ein Backup in deinem aktuellen Account wieder her — auch nach Registrierung eines neuen Accounts.",
|
||||
"backup_passphrase": "Backup-Passphrase",
|
||||
@@ -725,7 +843,13 @@
|
||||
"backup_export_btn": "Backup herunterladen",
|
||||
"backup_exporting": "Backup wird erstellt…",
|
||||
"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_previewing": "Prüfe…",
|
||||
"backup_restore_btn": "Wiederherstellen",
|
||||
@@ -883,7 +1007,7 @@
|
||||
},
|
||||
"welcome_public": {
|
||||
"title": "Willkommen an Bord!",
|
||||
"body": "Erkunde unser Demo-Logbuch mit drei Reisetagen in der Kieler Förde – ganz ohne Account. Diese Tour zeigt dir Schiffsdaten, Mannschaftsauswahl und Logbucheinträge. Die Stammcrew pflegst du später im Benutzerprofil."
|
||||
"body": "Erkunde unser Demo-Logbuch mit drei Reisetagen in der Kieler Förde – ganz ohne Account. Die Tour zeigt dir Logbucheinträge, die Schiff- und Crew-Auswahl für dieses Logbuch. Flotte und Stammcrew pflegst du später im Benutzerprofil."
|
||||
},
|
||||
"nav_logs": {
|
||||
"title": "Logbucheinträge",
|
||||
@@ -902,15 +1026,19 @@
|
||||
"body": "Lade GPX-Dateien hoch oder sieh bereits gespeicherte Routen auf der Karte – inklusive Distanz und Geschwindigkeit."
|
||||
},
|
||||
"nav_vessel": {
|
||||
"title": "Schiffsdaten",
|
||||
"body": "Hinterlege Name, Maße und technische Daten deiner Yacht – einmal ausfüllen, für alle Reisetage verfügbar."
|
||||
"title": "Schiff fürs Logbuch",
|
||||
"body": "Wähle aus deiner Schiffsflotte das Schiff für dieses Logbuch. Schiffe pflegst du im Benutzerprofil unter Flotte & Crew."
|
||||
},
|
||||
"profile_vessel_pool": {
|
||||
"title": "Schiffsflotte",
|
||||
"body": "Im Benutzerprofil legst du alle deine Schiffe an – Charteryachten, eigenes Boot usw. Pro Logbuch wählst du dann das passende Schiff."
|
||||
},
|
||||
"profile_crew_pool": {
|
||||
"title": "Stammcrew & Skipper",
|
||||
"body": "Im Benutzerprofil pflegst du deinen Personen-Pool – mehrere Skipper (z. B. Charter) und Crew-Mitglieder für alle Logbücher."
|
||||
},
|
||||
"nav_logbook_crew": {
|
||||
"title": "Mannschaft pro Logbuch",
|
||||
"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": {
|
||||
|
||||
+159
-31
@@ -6,6 +6,10 @@
|
||||
"beta": "Beta",
|
||||
"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": {
|
||||
"de": "Deutsch",
|
||||
"en": "English",
|
||||
@@ -27,8 +31,10 @@
|
||||
"common": {
|
||||
"unsaved_changes_title": "Unsaved changes",
|
||||
"unsaved_changes_message": "You have unsaved changes. Leave this page anyway? Your changes will be lost.",
|
||||
"unsaved_changes_leave": "Leave",
|
||||
"unsaved_changes_stay": "Stay"
|
||||
"unsaved_changes_stay": "Stay",
|
||||
"unsaved_changes_save_leave": "Save & leave",
|
||||
"unsaved_changes_discard": "Discard",
|
||||
"unsaved_changes_leave": "Leave"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
@@ -37,7 +43,8 @@
|
||||
"deviation": "Deviation Table",
|
||||
"logs": "Logbook Entries",
|
||||
"stats": "Statistics",
|
||||
"settings": "Settings"
|
||||
"settings": "Settings",
|
||||
"admin": "Admin"
|
||||
},
|
||||
"auth": {
|
||||
"welcome": "Welcome to Kapteins Daagbok",
|
||||
@@ -84,7 +91,15 @@
|
||||
"use_localhost_link": "Switch to localhost",
|
||||
"error_passkey_cancelled": "Passkey sign-in was cancelled or timed out. Please try again.",
|
||||
"error_invalid_rp_id": "Passkey domain mismatch (RP ID). For local dev use http://localhost:5173 with RP_ID=localhost in .env.",
|
||||
"error_session_incomplete": "Sign-in incomplete. Please sign in with your passkey again."
|
||||
"error_session_incomplete": "Sign-in incomplete. Please sign in with your passkey again.",
|
||||
"restore_checking": "Checking session…",
|
||||
"restore_title": "Restore session",
|
||||
"restore_subtitle": "You are still signed in. Unlock your logbook with passkey or PIN.",
|
||||
"restore_unlocking": "Unlocking…",
|
||||
"restore_with_passkey": "Unlock with passkey ({{name}})",
|
||||
"restore_with_pin": "Unlock with PIN",
|
||||
"restore_pin_warning": "Enter your local PIN to unlock your logbook after reload.",
|
||||
"restore_other_account": "Sign in with another account"
|
||||
},
|
||||
"pwa": {
|
||||
"title": "Install app",
|
||||
@@ -243,13 +258,13 @@
|
||||
"live_sails_confirm": "Log entry",
|
||||
"live_sails_confirm_count": "Log entry ({{count}})",
|
||||
"live_sails": "Sails: {{sails}}",
|
||||
"live_fix": "Fix",
|
||||
"live_fix_coords": "Fix {{lat}}, {{lng}}",
|
||||
"live_fix_manual_hint": "GPS unavailable. Enter latitude and longitude manually, or try again with the GPS button.",
|
||||
"live_fix_gps_loading": "Getting GPS position…",
|
||||
"live_fix_invalid": "Please enter valid coordinates (latitude −90…90, longitude −180…180).",
|
||||
"live_fix_lat_placeholder": "Latitude (Lat)",
|
||||
"live_fix_lng_placeholder": "Longitude (Lng)",
|
||||
"live_position": "Position",
|
||||
"live_position_coords": "Position {{lat}}, {{lng}}",
|
||||
"live_position_manual_hint": "GPS unavailable. Enter latitude and longitude manually, or try again with the GPS button.",
|
||||
"live_position_gps_loading": "Getting GPS position…",
|
||||
"live_position_invalid": "Please enter valid coordinates (latitude −90…90, longitude −180…180).",
|
||||
"live_position_lat_placeholder": "Latitude (Lat)",
|
||||
"live_position_lng_placeholder": "Longitude (Lng)",
|
||||
"live_photo_btn": "Photo (camera)",
|
||||
"live_photo_capture_btn": "Capture",
|
||||
"live_photo_save_btn": "Save",
|
||||
@@ -260,44 +275,70 @@
|
||||
"live_photo_camera_starting": "Starting camera…",
|
||||
"live_photo_camera_denied": "Camera access denied or unavailable.",
|
||||
"live_photo_camera_unavailable": "Camera is not supported in this browser.",
|
||||
"live_photo_no_camera": "No camera is available on this device.",
|
||||
"live_photo_error": "Could not save photo.",
|
||||
"live_photo_entry": "Photo: {{caption}}",
|
||||
"live_photo_entry_plain": "Photo captured",
|
||||
"live_undo_photo_hint": "Photo saved",
|
||||
"live_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_voice_transcribe_action": "Transcribe",
|
||||
"live_voice_transcribing": "Transcribing…",
|
||||
"live_voice_transcribe_failed": "Voice memo saved, but transcription failed.",
|
||||
"live_undo_voice_hint": "Voice memo saved",
|
||||
"live_comment_btn": "Comment",
|
||||
"live_comment_placeholder": "Enter text…",
|
||||
"live_comment_confirm": "Log entry",
|
||||
"live_gps_error": "Could not determine GPS position.",
|
||||
"live_gps_start_hint": "Always start your day's voyage with a position fix.",
|
||||
"live_gps_start_hint": "Always start your day's voyage with a position.",
|
||||
"live_event_generic": "Event",
|
||||
"live_weather_btn": "Weather",
|
||||
"live_weather_owm_btn": "Fetch OpenWeatherMap weather",
|
||||
"live_weather_owm_loading": "Loading weather…",
|
||||
"live_weather_fix_required": "Log a GPS fix first (Fix button) to fetch OpenWeatherMap weather. The position must be at most 6 hours old.",
|
||||
"live_weather_fix_stale": "The last GPS fix is older than 6 hours. Log a new fix before fetching weather.",
|
||||
"live_weather_position_required": "Log a position first (Position button) to fetch OpenWeatherMap weather. The position must be at most 6 hours old.",
|
||||
"live_weather_position_stale": "The last position is older than 6 hours. Log a new position before fetching weather.",
|
||||
"live_wind_btn": "Wind",
|
||||
"live_temp_btn": "Temp °C",
|
||||
"live_pressure_btn": "Pressure",
|
||||
"live_precip_btn": "Precipitation",
|
||||
"live_sea_state_btn": "Sea state",
|
||||
"live_visibility_btn": "Visibility",
|
||||
"live_course_btn": "Course",
|
||||
"live_fuel_btn": "Fuel",
|
||||
"live_water_btn": "Water",
|
||||
"live_fuel_btn": "+ Fuel",
|
||||
"live_water_btn": "+ Water",
|
||||
"live_wind_entry": "Wind {{value}}",
|
||||
"live_temp_entry": "Temperature {{temp}} °C",
|
||||
"live_pressure_entry": "Pressure {{value}} hPa",
|
||||
"live_precip_entry": "Precipitation {{value}}",
|
||||
"live_sea_state_entry": "Sea state {{value}}",
|
||||
"live_visibility_entry": "Visibility {{value}}",
|
||||
"live_course_entry": "Course {{course}}",
|
||||
"live_fuel_entry": "Fuel +{{liters}} L",
|
||||
"live_water_entry": "Water +{{liters}} L",
|
||||
"live_auto_position": "Auto position",
|
||||
"live_undo_hint": "Entry saved",
|
||||
"live_undo_btn": "Undo",
|
||||
"live_cancel": "Cancel",
|
||||
"live_pressure_placeholder": "e.g. 1013",
|
||||
"live_temp_placeholder": "e.g. 18",
|
||||
"live_precip_placeholder": "e.g. light rain",
|
||||
"live_sea_state_placeholder": "e.g. 3",
|
||||
"live_visibility_placeholder": "e.g. 10 km",
|
||||
"live_course_placeholder": "e.g. 245",
|
||||
"live_fuel_placeholder": "Liters refilled",
|
||||
"live_water_placeholder": "Liters refilled",
|
||||
@@ -315,6 +356,7 @@
|
||||
"carry_over_tanks_yes": "Carry over",
|
||||
"carry_over_tanks_no": "Start at 0",
|
||||
"event_title": "Chronological Event Logbook",
|
||||
"event_creator": "Entered by",
|
||||
"no_events": "No events logged for this travel day yet.",
|
||||
"event_time": "Time",
|
||||
"event_mgk": "MgK Course",
|
||||
@@ -339,6 +381,12 @@
|
||||
"event_wind_direction": "Wind Dir",
|
||||
"event_wind_strength": "Wind Str",
|
||||
"event_sea_state": "Sea State",
|
||||
"event_visibility": "Visibility",
|
||||
"event_visibility_placeholder": "e.g. 10 km",
|
||||
"weather_slider_unset": "—",
|
||||
"weather_slider_pressure": "{{value}} hPa",
|
||||
"weather_slider_sea_state": "State {{value}}",
|
||||
"weather_slider_heel": "{{value}}°",
|
||||
"event_weather": "Weather",
|
||||
"event_log": "Log (nm)",
|
||||
"event_gps": "GPS Position",
|
||||
@@ -346,7 +394,26 @@
|
||||
"event_location_placeholder": "e.g. Kiel",
|
||||
"event_remarks": "Remarks / Events",
|
||||
"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_offline": "OpenWeatherMap requires an internet connection. You are currently offline.",
|
||||
"event_wind_pressure": "Barometer (hPa)",
|
||||
"event_heel": "Heel Angle (°)",
|
||||
"event_sails": "Sails / Motor Status",
|
||||
@@ -360,6 +427,18 @@
|
||||
"share_csv": "Share CSV",
|
||||
"export_pdf": "Download 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)",
|
||||
"photo_caption_label": "Photo Caption / Label (Optional)",
|
||||
"photo_caption_placeholder": "e.g. Setting sails near harbor entrance",
|
||||
@@ -428,8 +507,8 @@
|
||||
"nmea_change_engine_stop": "Engine off",
|
||||
"nmea_change_autopilot_on": "Autopilot on",
|
||||
"nmea_change_autopilot_off": "Autopilot off",
|
||||
"nmea_change_gps_lost": "GPS fix lost",
|
||||
"nmea_change_gps_regained": "GPS fix restored",
|
||||
"nmea_change_gps_lost": "GPS position lost",
|
||||
"nmea_change_gps_regained": "GPS position restored",
|
||||
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
|
||||
"nmea_change_departure": "Departure / underway",
|
||||
"nmea_change_anchor": "Anchored / stop",
|
||||
@@ -453,7 +532,7 @@
|
||||
"new_logbook_placeholder": "Logbook or Yacht Name",
|
||||
"logout": "Logout",
|
||||
"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!",
|
||||
"loading": "Loading logbooks...",
|
||||
"status_synced": "Synced",
|
||||
@@ -475,7 +554,7 @@
|
||||
"edit_success": "Logbook renamed successfully",
|
||||
"edit_btn": "Rename",
|
||||
"filter_label": "Filter logbooks",
|
||||
"filter_placeholder": "Name, year or date …",
|
||||
"filter_placeholder": "Name, year, date, crew or vessel …",
|
||||
"filter_clear": "Clear filter",
|
||||
"filter_results": "{{count}} matches",
|
||||
"filter_no_results": "No logbooks match your search. Try a different name or year.",
|
||||
@@ -593,6 +672,12 @@
|
||||
"integrations_title": "Integrations",
|
||||
"owm_key": "OpenWeatherMap API key",
|
||||
"owm_help": "Optional: your own OpenWeatherMap API key. If left empty, the operator-configured server key is used.",
|
||||
"ai_title": "AI Features & Privacy",
|
||||
"ai_desc": "Authorize artificial intelligence integrations for your logbooks.",
|
||||
"ai_help": "Enabling AI features allows the app to summarize travel days and transcribe recorded voice memos. To process these requests, raw voice data and travel logs are sent securely on-the-fly to OpenRouter. No data is stored permanently by the AI model.\n\nThese cloud resources cost money to run; if you enjoy using them, please consider supporting the project voluntarily with a donation via the Ko-fi link in the footer to keep them free and sustainable for everyone.",
|
||||
"ai_enable_label": "Enable transcription and travel day summaries",
|
||||
"ai_unauthorized_alert_title": "AI Features Not Authorized",
|
||||
"ai_unauthorized_alert_desc": "To use transcription or travel day summaries, please authorize the data transmission to OpenRouter in your User Profile under 'AI Features & Privacy'.",
|
||||
"prefs_save": "Save",
|
||||
"prefs_saving": "Saving…",
|
||||
"prefs_saved": "Saved",
|
||||
@@ -606,14 +691,44 @@
|
||||
"push_unsupported": "Push notifications are not supported in this browser.",
|
||||
"push_denied_hint": "Notifications are blocked. Allow them in your browser or device settings.",
|
||||
"push_ios_install_hint": "On iPhone/iPad: add the app to your Home Screen (iOS 16.4+) to use push notifications.",
|
||||
"push_error": "Could not enable push notifications."
|
||||
"push_error": "Could not enable push notifications.",
|
||||
"sections": {
|
||||
"account": "Account & settings",
|
||||
"fleet": "Fleet & crew",
|
||||
"security": "Security & device",
|
||||
"stats": "Statistics",
|
||||
"danger": "Danger zone"
|
||||
}
|
||||
},
|
||||
"vessel_pool": {
|
||||
"title": "Vessel fleet",
|
||||
"section_title": "Your vessels",
|
||||
"subtitle": "Maintain all vessels for your logbooks here. Select the active vessel per logbook from this list.",
|
||||
"loading": "Loading vessel fleet…",
|
||||
"add_vessel": "Add vessel",
|
||||
"edit_vessel": "Edit vessel",
|
||||
"no_vessels": "No vessels in the pool yet.",
|
||||
"delete_confirm": "Remove this vessel from the fleet?",
|
||||
"max_vessels": "Maximum of 20 vessels in the pool reached."
|
||||
},
|
||||
"logbook_vessel": {
|
||||
"title": "Vessel for this logbook",
|
||||
"subtitle": "Choose the vessel for this logbook. Travel days use sails and tank data from the selected vessel.",
|
||||
"active_vessel": "Vessel for this logbook",
|
||||
"no_vessels_in_pool": "No vessel in the fleet — add one in your user profile first.",
|
||||
"no_vessel": "No vessel selected",
|
||||
"unnamed": "Unnamed",
|
||||
"save": "Save vessel",
|
||||
"saved": "Logbook vessel saved.",
|
||||
"selection_only_hint": "You see the vessel chosen by the owner (shared logbook).",
|
||||
"manage_in_profile": "Manage vessels in user profile"
|
||||
},
|
||||
"person_pool": {
|
||||
"title": "Core crew & skippers",
|
||||
"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",
|
||||
"crew_section": "Core Crew",
|
||||
"add_skipper": "Add skipper",
|
||||
"add_crew": "Add crew member",
|
||||
"edit_skipper": "Edit skipper",
|
||||
@@ -684,6 +799,9 @@
|
||||
"no_key": "No OpenWeatherMap API key available. Add your own key in your user profile or contact the operator.",
|
||||
"weather_success": "Weather details fetched successfully!",
|
||||
"weather_error": "Failed to fetch weather. Check your API key and connection.",
|
||||
"weather_unauthorized": "Failed to fetch weather. The API key is invalid or unauthorized.",
|
||||
"weather_not_found": "Failed to fetch weather. The specified location or coordinates were not found.",
|
||||
"weather_bad_request": "Failed to fetch weather. No location or GPS position was specified.",
|
||||
"weather_date_mismatch": "Weather data can only be fetched for today ({{today}}). This logbook entry is dated {{date}}.",
|
||||
"gps_error": "Please enter a location or fetch GPS coordinates first.",
|
||||
"share_title": "Share Logbook (Read-Only)",
|
||||
@@ -702,7 +820,7 @@
|
||||
"delete_account_confirm_yes": "Yes, Delete Account and All Data",
|
||||
"delete_account_confirm_no": "Cancel",
|
||||
"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…",
|
||||
"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.",
|
||||
@@ -711,9 +829,9 @@
|
||||
"invite_push_prompt_later": "Later",
|
||||
"invite_push_prompt_success": "Push notifications are active on this device.",
|
||||
"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_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_desc": "Restores a backup into your current account — including after registering a new account.",
|
||||
"backup_passphrase": "Backup passphrase",
|
||||
@@ -725,7 +843,13 @@
|
||||
"backup_export_btn": "Download backup",
|
||||
"backup_exporting": "Creating backup…",
|
||||
"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_previewing": "Verifying…",
|
||||
"backup_restore_btn": "Restore",
|
||||
@@ -883,7 +1007,7 @@
|
||||
},
|
||||
"welcome_public": {
|
||||
"title": "Welcome aboard!",
|
||||
"body": "Explore our demo logbook with three travel days in Kiel Bay — no account required. This short tour shows vessel data, crew, and log entries."
|
||||
"body": "Explore our demo logbook with three travel days in Kiel Bay — no account required. The tour covers log entries and vessel and crew selection for this logbook. Manage your fleet and core crew later in your user profile."
|
||||
},
|
||||
"nav_logs": {
|
||||
"title": "Log entries",
|
||||
@@ -902,11 +1026,15 @@
|
||||
"body": "Upload GPX files or view saved routes on the map – including distance and speed stats."
|
||||
},
|
||||
"nav_vessel": {
|
||||
"title": "Vessel data",
|
||||
"body": "Enter your yacht's name, dimensions, and technical details – fill once, use on every travel day."
|
||||
"title": "Vessel for logbook",
|
||||
"body": "Choose a vessel from your fleet for this logbook. Manage vessels in your user profile under Fleet & crew."
|
||||
},
|
||||
"profile_vessel_pool": {
|
||||
"title": "Vessel fleet",
|
||||
"body": "In your user profile you add all your vessels — charter yachts, your own boat, etc. Then pick the right vessel per logbook."
|
||||
},
|
||||
"profile_crew_pool": {
|
||||
"title": "Core crew & skippers",
|
||||
"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": {
|
||||
|
||||
+205
-77
@@ -6,6 +6,10 @@
|
||||
"beta": "Beta",
|
||||
"beta_hint": "Betaversjon - funksjoner kan fortsatt endres"
|
||||
},
|
||||
"footer": {
|
||||
"kofi_label": "Ko-fi",
|
||||
"kofi_title": "Støtt prosjektet, videreutvikling og driftskostnader på Ko-fi"
|
||||
},
|
||||
"languages": {
|
||||
"de": "Deutsch",
|
||||
"en": "English",
|
||||
@@ -27,17 +31,20 @@
|
||||
"common": {
|
||||
"unsaved_changes_title": "Ikke-lagrede endringer",
|
||||
"unsaved_changes_message": "Du har endringer som ikke er lagret. Vil du virkelig forlate siden? Endringene dine vil gå tapt.",
|
||||
"unsaved_changes_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": {
|
||||
"dashboard": "Dashbord",
|
||||
"vessel": "Skipsdata",
|
||||
"crew": "Mannskapsliste",
|
||||
"crew": "Crew",
|
||||
"deviation": "Tabell over distraksjoner",
|
||||
"logs": "Loggbokoppføringer",
|
||||
"stats": "Statistikk",
|
||||
"settings": "Innstillinger"
|
||||
"settings": "Innstillinger",
|
||||
"admin": "Admin"
|
||||
},
|
||||
"auth": {
|
||||
"welcome": "Velkommen til Kapteins Daagbok",
|
||||
@@ -84,7 +91,15 @@
|
||||
"use_localhost_link": "Bytt til localhost",
|
||||
"error_passkey_cancelled": "Passkey-innlogging ble avbrutt eller utløp. Prøv igjen.",
|
||||
"error_invalid_rp_id": "Passkey-domene stemmer ikke (RP ID). Bruk http://localhost:5173 med RP_ID=localhost i .env for lokal utvikling.",
|
||||
"error_session_incomplete": "Innlogging ufullstendig. Logg inn med passkey igjen."
|
||||
"error_session_incomplete": "Innlogging ufullstendig. Logg inn med passkey igjen.",
|
||||
"restore_checking": "Sjekker økt…",
|
||||
"restore_title": "Gjenopprett økt",
|
||||
"restore_subtitle": "Du er fortsatt innlogget. Lås opp loggboken med passkey eller PIN.",
|
||||
"restore_unlocking": "Låser opp…",
|
||||
"restore_with_passkey": "Lås opp med passkey ({{name}})",
|
||||
"restore_with_pin": "Lås opp med PIN",
|
||||
"restore_pin_warning": "Skriv inn din lokale PIN for å låse opp loggboken etter omlasting.",
|
||||
"restore_other_account": "Logg inn med en annen konto"
|
||||
},
|
||||
"pwa": {
|
||||
"title": "Installer app",
|
||||
@@ -183,7 +198,7 @@
|
||||
"consumption": "Daglig forbruk",
|
||||
"signatures": "Underskrifter / frigivelse",
|
||||
"sign_skipper": "Skippers signatur",
|
||||
"sign_crew": "Mannskapets signatur",
|
||||
"sign_crew": "Crews signatur",
|
||||
"sign_hint": "Signer med finger, penn eller mus",
|
||||
"sign_clear": "Slett",
|
||||
"sign_export_image": "[Signatur]",
|
||||
@@ -203,16 +218,16 @@
|
||||
"sign_badge_skipper_title_valid": "Skipper har gitt ut",
|
||||
"sign_badge_skipper_title_invalid": "Skippersignaturen er ugyldig - innholdet har blitt endret",
|
||||
"sign_classic_or_passkey": "Valgfritt: klassisk signatur eller Passkey utgivelse ovenfor",
|
||||
"sign_crew_passkey_hint": "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_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_active": "Denne oppføringen er signert. Endringer i loggboken (unntatt bilder) fjerner automatisk skipperens og mannskapets signaturer.",
|
||||
"sign_lock_notice": "Etter signering er det ikke mulig å gjøre endringer i loggbokoppføringen (unntatt bilder) uten at skipper og crew må signere på nytt.",
|
||||
"sign_lock_active": "Denne oppføringen er signert. Endringer i loggboken (unntatt bilder) fjerner automatisk skipperens og crews signaturer.",
|
||||
"sign_lock_warning_title": "Bekreft signatur",
|
||||
"sign_lock_warning": "Etter signering er det ikke lenger mulig å gjøre endringer i loggbokoppføringen (unntatt bilder) uten at skipper og 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_cancel": "Avbryt",
|
||||
"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!",
|
||||
"back_to_list": "Tilbake til tidsskriftlisten",
|
||||
"save": "Lagre loggbokside",
|
||||
@@ -243,13 +258,13 @@
|
||||
"live_sails_confirm": "Loggfør",
|
||||
"live_sails_confirm_count": "Loggfør ({{count}})",
|
||||
"live_sails": "Seil: {{sails}}",
|
||||
"live_fix": "Fix",
|
||||
"live_fix_coords": "Fix {{lat}}, {{lng}}",
|
||||
"live_fix_manual_hint": "GPS ikke tilgjengelig. Skriv inn bredde- og lengdegrad manuelt, eller prøv igjen med GPS-knappen.",
|
||||
"live_fix_gps_loading": "Henter GPS-posisjon…",
|
||||
"live_fix_invalid": "Skriv inn gyldige koordinater (bredde −90…90, lengde −180…180).",
|
||||
"live_fix_lat_placeholder": "Bredde (Lat)",
|
||||
"live_fix_lng_placeholder": "Lengde (Lng)",
|
||||
"live_position": "Posisjon",
|
||||
"live_position_coords": "Posisjon {{lat}}, {{lng}}",
|
||||
"live_position_manual_hint": "GPS ikke tilgjengelig. Skriv inn bredde- og lengdegrad manuelt, eller prøv igjen med GPS-knappen.",
|
||||
"live_position_gps_loading": "Henter GPS-posisjon…",
|
||||
"live_position_invalid": "Skriv inn gyldige koordinater (bredde −90…90, lengde −180…180).",
|
||||
"live_position_lat_placeholder": "Bredde (Lat)",
|
||||
"live_position_lng_placeholder": "Lengde (Lng)",
|
||||
"live_photo_btn": "Foto (kamera)",
|
||||
"live_photo_capture_btn": "Ta bilde",
|
||||
"live_photo_save_btn": "Lagre",
|
||||
@@ -260,10 +275,32 @@
|
||||
"live_photo_camera_starting": "Starter kamera…",
|
||||
"live_photo_camera_denied": "Kameratilgang nektet eller utilgjengelig.",
|
||||
"live_photo_camera_unavailable": "Kamera støttes ikke i denne nettleseren.",
|
||||
"live_photo_no_camera": "Ingen kamera er tilgjengelig på denne enheten.",
|
||||
"live_photo_error": "Kunne ikke lagre foto.",
|
||||
"live_photo_entry": "Foto: {{caption}}",
|
||||
"live_photo_entry_plain": "Foto tatt",
|
||||
"live_undo_photo_hint": "Foto lagret",
|
||||
"live_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_voice_transcribe_action": "Transkribere",
|
||||
"live_voice_transcribing": "Transkriberer…",
|
||||
"live_voice_transcribe_failed": "Taleopptak lagret, men transkribering mislyktes.",
|
||||
"live_undo_voice_hint": "Talemelding lagret",
|
||||
"live_comment_btn": "Kommentar",
|
||||
"live_comment_placeholder": "Skriv inn tekst…",
|
||||
"live_comment_confirm": "Loggfør",
|
||||
@@ -273,31 +310,35 @@
|
||||
"live_weather_btn": "Vær",
|
||||
"live_weather_owm_btn": "Hent OpenWeatherMap-vær",
|
||||
"live_weather_owm_loading": "Henter vær…",
|
||||
"live_weather_fix_required": "Logg først en GPS-fix (Fix-knapp) for å hente OpenWeatherMap-vær. Posisjonen må være maks 6 timer gammel.",
|
||||
"live_weather_fix_stale": "Siste GPS-fix er eldre enn 6 timer. Logg en ny fix før du henter vær.",
|
||||
"live_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_position_stale": "Siste posisjon er eldre enn 6 timer. Logg en ny posisjon før du henter vær.",
|
||||
"live_wind_btn": "Vind",
|
||||
"live_temp_btn": "T °C",
|
||||
"live_pressure_btn": "Lufttrykk",
|
||||
"live_precip_btn": "Nedbør",
|
||||
"live_sea_state_btn": "Sjøgang",
|
||||
"live_visibility_btn": "Sikt",
|
||||
"live_course_btn": "Kurs",
|
||||
"live_fuel_btn": "Diesel",
|
||||
"live_water_btn": "Vann",
|
||||
"live_fuel_btn": "+ Diesel",
|
||||
"live_water_btn": "+ Vann",
|
||||
"live_wind_entry": "Vind {{value}}",
|
||||
"live_temp_entry": "Temperatur {{temp}} °C",
|
||||
"live_pressure_entry": "Lufttrykk {{value}} hPa",
|
||||
"live_precip_entry": "Nedbør {{value}}",
|
||||
"live_sea_state_entry": "Sjøgang {{value}}",
|
||||
"live_visibility_entry": "Sikt {{value}}",
|
||||
"live_course_entry": "Kurs {{course}}",
|
||||
"live_fuel_entry": "Diesel +{{liters}} L",
|
||||
"live_water_entry": "Vann +{{liters}} L",
|
||||
"live_auto_position": "Auto-posisjon",
|
||||
"live_undo_hint": "Oppføring lagret",
|
||||
"live_undo_btn": "Angre",
|
||||
"live_cancel": "Avbryt",
|
||||
"live_pressure_placeholder": "f.eks. 1013",
|
||||
"live_temp_placeholder": "f.eks. 18",
|
||||
"live_precip_placeholder": "f.eks. lett regn",
|
||||
"live_sea_state_placeholder": "f.eks. 3",
|
||||
"live_visibility_placeholder": "f.eks. 10 km",
|
||||
"live_course_placeholder": "f.eks. 245",
|
||||
"live_fuel_placeholder": "Påfylte liter",
|
||||
"live_water_placeholder": "Påfylte liter",
|
||||
@@ -315,6 +356,7 @@
|
||||
"carry_over_tanks_yes": "Ta over",
|
||||
"carry_over_tanks_no": "Begynn med 0",
|
||||
"event_title": "Kronologisk hendelseslogg",
|
||||
"event_creator": "Registrert av",
|
||||
"no_events": "Ingen arrangementer lagt inn for denne reisedagen ennå.",
|
||||
"event_time": "Tid på døgnet",
|
||||
"event_mgk": "MgK-kurs",
|
||||
@@ -339,6 +381,12 @@
|
||||
"event_wind_direction": "Vindretning",
|
||||
"event_wind_strength": "Vindstyrke",
|
||||
"event_sea_state": "Havets tilstand",
|
||||
"event_visibility": "Sikt",
|
||||
"event_visibility_placeholder": "f.eks. 10 km",
|
||||
"weather_slider_unset": "—",
|
||||
"weather_slider_pressure": "{{value}} hPa",
|
||||
"weather_slider_sea_state": "Grad {{value}}",
|
||||
"weather_slider_heel": "{{value}}°",
|
||||
"event_weather": "Været",
|
||||
"event_log": "Logg (sm)",
|
||||
"event_gps": "GPS-posisjon",
|
||||
@@ -346,7 +394,26 @@
|
||||
"event_location_placeholder": "z. f.eks. Kiel",
|
||||
"event_remarks": "Merknader / hendelser",
|
||||
"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_offline": "OpenWeatherMap krever internettforbindelse. Du er frakoblet.",
|
||||
"event_wind_pressure": "Lufttrykk (hPa)",
|
||||
"event_heel": "Helning (°)",
|
||||
"event_sails": "Seilhåndtering / motor",
|
||||
@@ -360,6 +427,18 @@
|
||||
"share_csv": "CSV andel",
|
||||
"export_pdf": "Last ned PDF",
|
||||
"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)",
|
||||
"photo_caption_label": "Fotobeskrivelse/etikett (valgfritt)",
|
||||
"photo_caption_placeholder": "f.eks. å sette seil nær innseilingen til havnen",
|
||||
@@ -387,12 +466,12 @@
|
||||
"track_map_error": "Kartet kunne ikke lastes inn.",
|
||||
"exporting": "Eksport...",
|
||||
"share_unsupported": "Deling støttes ikke på denne enheten. Filen har blitt lastet ned i stedet.",
|
||||
"invite_crew": "Inviter mannskapet",
|
||||
"invite_crew": "Inviter crewet",
|
||||
"invite_link_copied": "Invitasjonslenke kopiert til utklippstavlen!",
|
||||
"invite_link_desc": "Del denne lenken med besetningsmedlemmene for å gi dem skrivetilgang til loggboken.",
|
||||
"collaborators_list": "Medlemmer / Besetning",
|
||||
"invite_link_desc": "Del denne lenken med Crew-medlemmene for å gi dem skrivetilgang til loggboken.",
|
||||
"collaborators_list": "Medlemmer / Crew",
|
||||
"revoke": "Fjern",
|
||||
"revoke_confirm": "Er du sikker på at du vil oppheve dette besetningsmedlemmets tilgang?",
|
||||
"revoke_confirm": "Er du sikker på at du vil oppheve dette Crew-medlemmets tilgang?",
|
||||
"invite_role": "Rolle",
|
||||
"invite_expires": "Lenken er gyldig i 48 timer",
|
||||
"nmea_import_title": "Import NMEA log",
|
||||
@@ -438,8 +517,8 @@
|
||||
"nmea_change_engine_stop": "Engine off",
|
||||
"nmea_change_autopilot_on": "Autopilot on",
|
||||
"nmea_change_autopilot_off": "Autopilot off",
|
||||
"nmea_change_gps_lost": "GPS fix lost",
|
||||
"nmea_change_gps_regained": "GPS fix restored",
|
||||
"nmea_change_gps_lost": "GPS-posisjon tapt",
|
||||
"nmea_change_gps_regained": "GPS-posisjon gjenopprettet",
|
||||
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
|
||||
"nmea_change_departure": "Departure / underway",
|
||||
"nmea_change_anchor": "Anchored / stop",
|
||||
@@ -453,7 +532,7 @@
|
||||
"new_logbook_placeholder": "Navn på loggboken eller båten",
|
||||
"logout": "Logg ut",
|
||||
"logged_in_as": "Innlogget som {{name}}",
|
||||
"delete_confirm": "Er du sikker på at du vil slette denne loggboken permanent? Alle lokale data og serverkopier vil bli ødelagt.\n\nTips: Lag en sikkerhetskopi (.daagbok.json) på forhånd under Innstillinger → Sikkerhetskopiering og gjenoppretting hvis du ønsker å beholde dataene senere.",
|
||||
"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!",
|
||||
"loading": "Loggbøker er lastet...",
|
||||
"status_synced": "Synkronisert",
|
||||
@@ -461,11 +540,11 @@
|
||||
"delete_btn": "Slett loggbok",
|
||||
"section_owned": "Loggbøkene mine",
|
||||
"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_hint": "Du er eier og skipper av denne loggboken",
|
||||
"role_crew": "Tilgang for mannskapet",
|
||||
"role_crew_hint": "Loggbok med invitasjon - du kan jobbe som mannskap og signere den",
|
||||
"role_crew": "Tilgang for crewet",
|
||||
"role_crew_hint": "Loggbok med invitasjon - du kan jobbe som crew og signere den",
|
||||
"role_read": "Bare les",
|
||||
"role_read_hint": "Delt loggbok - kun visning, ingen redigering",
|
||||
"open_profile": "Åpne profilen til {{name}}",
|
||||
@@ -475,7 +554,7 @@
|
||||
"edit_success": "Loggboken har fått nytt navn",
|
||||
"edit_btn": "Gi nytt navn",
|
||||
"filter_label": "Filtrer loggbøker",
|
||||
"filter_placeholder": "Navn, årstall eller dato ...",
|
||||
"filter_placeholder": "Navn, årstall, dato, crew eller skip …",
|
||||
"filter_clear": "Tilbakestill filter",
|
||||
"filter_results": "{{count}} Treff",
|
||||
"filter_no_results": "Ingen loggbøker samsvarer med søket ditt. Prøv et annet navn eller et annet år.",
|
||||
@@ -593,6 +672,12 @@
|
||||
"integrations_title": "Integrasjoner",
|
||||
"owm_key": "OpenWeatherMap API-nøkkel",
|
||||
"owm_help": "Valgfritt: egen OpenWeatherMap API-nøkkel. Hvis ingen oppføring er gjort, brukes serverside-nøkkelen fra operatørkonfigurasjonen.",
|
||||
"ai_title": "KI-funksjoner og personvern",
|
||||
"ai_desc": "Autoriser integrasjoner av kunstig intelligens for loggbøkene dine.",
|
||||
"ai_help": "Aktivering av KI-funksjoner gjør det mulig for appen å oppsummere reisedagene dine og transkribere innspilte talememoer. For å behandle disse forespørslene sendes rå stemmedata og reiselogger sikkert løpende til OpenRouter. Ingen data lagres permanent av KI-modellen.\n\nDisse nettskyressursene koster penger å drifte. Hvis du har glede av å bruke dem, kan du vurdere å støtte prosjektet frivillig med en donasjon via Ko-fi-lenken i bunnteksten for å holde dem gratis og bærekraftige for alle.",
|
||||
"ai_enable_label": "Aktiver transkribering og oppsummeringer av reisedager",
|
||||
"ai_unauthorized_alert_title": "KI-funktionen er ikke autorisert",
|
||||
"ai_unauthorized_alert_desc": "For å bruke transkribering eller reisedagsoppsummeringer, må du autorisere dataoverføringen til OpenRouter i brukerprofilen din under 'KI-funksjoner og personvern'.",
|
||||
"prefs_save": "Spar",
|
||||
"prefs_saving": "...vil bli reddet...",
|
||||
"prefs_saved": "Reddet",
|
||||
@@ -600,58 +685,88 @@
|
||||
"tour_desc": "La deg veilede gjennom de viktigste områdene i appen på nytt.",
|
||||
"tour_restart": "Start turen på nytt",
|
||||
"push_title": "Push-varsler",
|
||||
"push_desc": "Som loggbokseier vil du bli varslet når inviterte besetningsmedlemmer synkroniserer endringer. Ingen innhold overføres i ren tekst.",
|
||||
"push_enable": "Gi oss beskjed om endringer i mannskapet",
|
||||
"push_desc": "Som loggbokseier vil du bli varslet når inviterte Crew-medlemmer synkroniserer endringer. Ingen innhold overføres i ren tekst.",
|
||||
"push_enable": "Gi oss beskjed om endringer i crewet",
|
||||
"push_active": "Push-varsler er aktive på denne enheten.",
|
||||
"push_unsupported": "Push-varsler støttes ikke i denne nettleseren.",
|
||||
"push_denied_hint": "Varsler er blokkert. Tillat dem i innstillingene i nettleseren eller på enheten.",
|
||||
"push_ios_install_hint": "På iPhone/iPad: Legg til app på startskjermen (iOS 16.4+) for å bruke push.",
|
||||
"push_error": "Push-varsler kunne ikke aktiveres."
|
||||
"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": "Stammmannskap og skippere",
|
||||
"subtitle": "Hold personpoolen din her – skippere og mannskap for alle loggbøker. Velg aktivt mannskap per loggbok og reisedag fra poolen.",
|
||||
"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": "Stammmannskap",
|
||||
"crew_section": "Stamm-Crew",
|
||||
"add_skipper": "Legg til skipper",
|
||||
"add_crew": "Legg til mannskapsmedlem",
|
||||
"add_crew": "Legg til Crew-medlem",
|
||||
"edit_skipper": "Rediger skipper",
|
||||
"no_skippers": "Ingen skipper i poolen ennå.",
|
||||
"no_crew": "Ingen mannskapsmedlemmer i poolen ennå.",
|
||||
"no_crew": "Ingen Crew-medlemmer i poolen ennå.",
|
||||
"delete_confirm": "Fjerne denne personen fra poolen?"
|
||||
},
|
||||
"logbook_crew": {
|
||||
"title": "Mannskap for denne loggboken",
|
||||
"subtitle": "Velg skipper og mannskap for denne loggboken. Nye reisedager arver valget som standard.",
|
||||
"loading": "Laster mannskap…",
|
||||
"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": "Mannskap 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 mannskap 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 mannskap",
|
||||
"saved": "Loggbokmannskap lagret.",
|
||||
"selection_only_hint": "Du ser mannskapet eieren har valgt (delt loggbok)."
|
||||
"save": "Lagre crew",
|
||||
"saved": "Loggbok-Crew lagret.",
|
||||
"selection_only_hint": "Du ser crewet eieren har valgt (delt loggbok)."
|
||||
},
|
||||
"entry_crew": {
|
||||
"title": "Mannskap på denne reisedagen",
|
||||
"title": "Crew på denne reisedagen",
|
||||
"subtitle": "Kan avvike fra loggbokstandard. Følgende dager arver fra forrige dag.",
|
||||
"day_skipper": "Skipper denne dagen",
|
||||
"day_crew": "Mannskap denne dagen",
|
||||
"day_crew": "Crew denne dagen",
|
||||
"no_skipper": "Ingen skipper valgt",
|
||||
"no_crew": "Ingen mannskap valgt"
|
||||
"no_crew": "Ingen crew valgt"
|
||||
},
|
||||
"crew": {
|
||||
"title": "Skipper- og mannskapsprofiler",
|
||||
"title": "Skipper- og Crew-profiler",
|
||||
"skipper_section": "Skipperprofil",
|
||||
"skipper_read_only_hint": "Skipperprofilen kan bare redigeres av eieren av loggboken.",
|
||||
"crew_section": "Mannskapsliste",
|
||||
"add_crew": "Legg til besetningsmedlem",
|
||||
"edit_crew": "Rediger besetningsmedlem",
|
||||
"no_crew": "Ingen besetningsmedlemmer er lagt til ennå.",
|
||||
"max_crew": "Maksimalt antall på 12 besetningsmedlemmer i poolen er nådd.",
|
||||
"crew_section": "Crew-liste",
|
||||
"add_crew": "Legg til Crew-medlem",
|
||||
"edit_crew": "Rediger Crew-medlem",
|
||||
"no_crew": "Ingen Crew-medlemmer er lagt til ennå.",
|
||||
"max_crew": "Maksimalt antall på 12 Crew-medlemmer i poolen er nådd.",
|
||||
"name": "Navn",
|
||||
"address": "adresse",
|
||||
"birthdate": "Bursdag",
|
||||
@@ -664,8 +779,8 @@
|
||||
"save": "Lagre skipperdata",
|
||||
"save_member": "Lagre medlem",
|
||||
"saved": "Skipperprofilen er vellykket lagret!",
|
||||
"loading": "Mannskapsfilene er lastet inn...",
|
||||
"delete_confirm": "Er du sikker på at du vil fjerne dette besetningsmedlemmet?"
|
||||
"loading": "Crew-filene er lastet inn...",
|
||||
"delete_confirm": "Er du sikker på at du vil fjerne dette Crew-medlemmet?"
|
||||
},
|
||||
"deviation": {
|
||||
"title": "Tabell over kompassavvik",
|
||||
@@ -684,10 +799,13 @@
|
||||
"no_key": "Ingen OpenWeatherMap API-nøkkel tilgjengelig. Lagre din egen nøkkel i brukerprofilen, eller kontakt operatøren.",
|
||||
"weather_success": "Værdata vellykket hentet!",
|
||||
"weather_error": "Henting av værdata mislyktes. Kontroller API-nøkkelen og tilkoblingen.",
|
||||
"weather_unauthorized": "Henting av værdata mislyktes. API-nøkkelen er ugyldig eller ikke autorisert.",
|
||||
"weather_not_found": "Henting av værdata mislyktes. Den angitte posisjonen eller koordinatene ble ikke funnet.",
|
||||
"weather_bad_request": "Henting av værdata mislyktes. Ingen posisjon eller GPS-koordinater ble angitt.",
|
||||
"weather_date_mismatch": "Værdata kan bare hentes ut for i dag ({{today}}). Denne loggbokoppføringen er datert {{date}}.",
|
||||
"gps_error": "Vennligst skriv inn en posisjon eller finn GPS-koordinatene.",
|
||||
"share_title": "Del loggbok (skrivebeskyttet)",
|
||||
"share_desc": "Aktiver dette alternativet for å opprette en offentlig, skrivebeskyttet lenke. Alle som har denne lenken, kan se seilasene, båtprofilene og 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_enable": "Aktiver offentlig lenke",
|
||||
"share_copied": "Linken er kopiert!",
|
||||
@@ -695,25 +813,25 @@
|
||||
"link_qr_hint": "Skann QR-koden med telefonen",
|
||||
"link_qr_alt": "QR-kode for lenken",
|
||||
"danger_zone_title": "Faresone",
|
||||
"danger_zone_desc": "Hvis du sletter kontoen din, slettes alle dine Passkeys, loggbøker, skipsdata, mannskapsprofiler, reiseoppføringer og E2E-nøkler ugjenkallelig. Denne handlingen kan ikke angres.",
|
||||
"danger_zone_desc": "Hvis du sletter kontoen din, slettes alle dine Passkeys, loggbøker, skipsdata, Crew-profiler, reiseoppføringer og E2E-nøkler ugjenkallelig. Denne handlingen kan ikke angres.",
|
||||
"delete_account_btn": "Slett konto ugjenkallelig",
|
||||
"delete_account_confirm_title": "Slett konto?",
|
||||
"delete_account_confirm_desc": "Er du helt sikker på at du vil slette kontoen din og alle tilknyttede loggbøker og E2E-krypterte data ugjenkallelig?",
|
||||
"delete_account_confirm_yes": "Ja, slett konto og alle data",
|
||||
"delete_account_confirm_no": "Avbryt",
|
||||
"delete_account_failed": "Kontoen kunne ikke slettes. Vennligst prøv igjen.",
|
||||
"delete_backup_hint": "Tips: Lag sikkerhetskopier av loggbøkene dine (.daagbok.json) i innstillingene for hver loggbok før du sletter dem.",
|
||||
"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...",
|
||||
"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_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_message": "Så snart inviterte Crew-medlemmer synkroniserer endringer, kan du bli informert via push. Ingen loggbokinnhold sendes i ren tekst.",
|
||||
"invite_push_prompt_ios_message": "Så snart Crew-medlemmene synkroniserer endringer, kan du bli informert via push. På iPhone/iPad: Legg til appen på startskjermen (iOS 16.4+), og aktiver deretter push i brukerprofilen.",
|
||||
"invite_push_prompt_enable": "Aktiver nå",
|
||||
"invite_push_prompt_later": "Senere",
|
||||
"invite_push_prompt_success": "Push-varsler er aktive på denne enheten.",
|
||||
"backup_title": "Sikkerhetskopiering og gjenoppretting",
|
||||
"backup_desc": "Fullstendig kryptert sikkerhetskopi av denne loggboken (oppføringer, bilder, GPS-spor, 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_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_desc": "Gjenoppretter en sikkerhetskopi til din nåværende konto - selv etter at du har registrert en ny konto.",
|
||||
"backup_passphrase": "Passord for sikkerhetskopiering",
|
||||
@@ -725,7 +843,13 @@
|
||||
"backup_export_btn": "Last ned sikkerhetskopi",
|
||||
"backup_exporting": "Sikkerhetskopien er opprettet...",
|
||||
"backup_export_success": "Sikkerhetskopi opprettet ({{count}} reisedager).",
|
||||
"backup_file_label": "Sikkerhetskopifil (.daagbok.json)",
|
||||
"backup_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_previewing": "Sjekk...",
|
||||
"backup_restore_btn": "Gjenopprett",
|
||||
@@ -741,7 +865,7 @@
|
||||
"backup_new_id_confirm": "Importere sikkerhetskopien som en ny loggbok med ny ID?",
|
||||
"backup_stat_entries": "{{count}} Reisedager",
|
||||
"backup_stat_photos": "{{count}} Bilder",
|
||||
"backup_stat_crew": "{{count}} Mannskapsposter",
|
||||
"backup_stat_crew": "{{count}} Crew-poster",
|
||||
"backup_stat_tracks": "{{count}} GPS-spor",
|
||||
"backup_exported_at": "Eksportert: {{date}}"
|
||||
},
|
||||
@@ -883,7 +1007,7 @@
|
||||
},
|
||||
"welcome_public": {
|
||||
"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": {
|
||||
"title": "Loggbokoppføringer",
|
||||
@@ -902,16 +1026,20 @@
|
||||
"body": "Last opp GPX-filer eller se allerede lagrede ruter på kartet - inkludert avstand og hastighet."
|
||||
},
|
||||
"nav_vessel": {
|
||||
"title": "Skipsdata",
|
||||
"body": "Skriv inn navn, dimensjoner og tekniske data for båten din - fyll inn én gang, tilgjengelig for alle reisedager."
|
||||
"title": "Skip for loggbok",
|
||||
"body": "Velg skip fra flåten for denne loggboken. Administrer skip i brukerprofilen under Flåte og crew."
|
||||
},
|
||||
"profile_vessel_pool": {
|
||||
"title": "Skipsflåte",
|
||||
"body": "I brukerprofilen legger du inn alle skip – charter, eget båt osv. Velg deretter riktig skip per loggbok."
|
||||
},
|
||||
"profile_crew_pool": {
|
||||
"title": "Stammmannskap og skippere",
|
||||
"body": "I brukerprofilen vedlikeholder du en personpool – flere skippere (f.eks. charter) og mannskap for alle loggbøker."
|
||||
"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": "Mannskap per loggbok",
|
||||
"body": "Velg skipper og mannskap fra poolen for denne loggboken. Reisedager arver valget som standard."
|
||||
"title": "Crew per loggbok",
|
||||
"body": "Velg skipper og crew fra poolen for denne loggboken. Reisedager arver valget som standard."
|
||||
},
|
||||
"nav_stats": {
|
||||
"title": "Dashbord for statistikk",
|
||||
@@ -937,7 +1065,7 @@
|
||||
},
|
||||
"seo": {
|
||||
"title": "Kapteins Daagbok - Gratis digital loggbok for fritidsbåter (uten reklame)",
|
||||
"description": "Gratis, annonsefri digital loggbok med ende-til-ende-kryptering og Passkey-pålogging. Dokumenter seilingsdager, GPS-spor, 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",
|
||||
"ogImageAlt": "Kapteins Daagbok Logo"
|
||||
}
|
||||
|
||||
+206
-78
@@ -6,6 +6,10 @@
|
||||
"beta": "Beta",
|
||||
"beta_hint": "Betaversion - funktioner kan fortfarande ändras"
|
||||
},
|
||||
"footer": {
|
||||
"kofi_label": "Ko-fi",
|
||||
"kofi_title": "Stöd projektet, vidareutveckling och driftskostnader på Ko-fi"
|
||||
},
|
||||
"languages": {
|
||||
"de": "Deutsch",
|
||||
"en": "English",
|
||||
@@ -27,17 +31,20 @@
|
||||
"common": {
|
||||
"unsaved_changes_title": "Osparade ändringar",
|
||||
"unsaved_changes_message": "Du har ändringar som inte sparats. Vill du verkligen lämna sidan? Dina ändringar kommer att gå förlorade.",
|
||||
"unsaved_changes_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": {
|
||||
"dashboard": "Instrumentpanel",
|
||||
"vessel": "Fartygsdata",
|
||||
"crew": "Besättningslista",
|
||||
"crew": "Crew",
|
||||
"deviation": "Distraktionsbord",
|
||||
"logs": "Loggboksanteckningar",
|
||||
"stats": "Statistik",
|
||||
"settings": "Inställningar"
|
||||
"settings": "Inställningar",
|
||||
"admin": "Admin"
|
||||
},
|
||||
"auth": {
|
||||
"welcome": "Välkommen till Kapteins Daagbok",
|
||||
@@ -84,7 +91,15 @@
|
||||
"use_localhost_link": "Byt till localhost",
|
||||
"error_passkey_cancelled": "Passkey-inloggning avbröts eller gick ut. Försök igen.",
|
||||
"error_invalid_rp_id": "Passkey-domänen matchar inte (RP ID). Använd http://localhost:5173 med RP_ID=localhost i .env för lokal utveckling.",
|
||||
"error_session_incomplete": "Inloggning ofullständig. Logga in med passkey igen."
|
||||
"error_session_incomplete": "Inloggning ofullständig. Logga in med passkey igen.",
|
||||
"restore_checking": "Kontrollerar session…",
|
||||
"restore_title": "Återställ session",
|
||||
"restore_subtitle": "Du är fortfarande inloggad. Lås upp din loggbok med passkey eller PIN.",
|
||||
"restore_unlocking": "Låser upp…",
|
||||
"restore_with_passkey": "Lås upp med passkey ({{name}})",
|
||||
"restore_with_pin": "Lås upp med PIN",
|
||||
"restore_pin_warning": "Ange din lokala PIN för att låsa upp loggboken efter omladdning.",
|
||||
"restore_other_account": "Logga in med ett annat konto"
|
||||
},
|
||||
"pwa": {
|
||||
"title": "Installera app",
|
||||
@@ -183,7 +198,7 @@
|
||||
"consumption": "Daglig konsumtion",
|
||||
"signatures": "Underskrifter / frisläppande",
|
||||
"sign_skipper": "Skepparens signatur",
|
||||
"sign_crew": "Besättningens signatur",
|
||||
"sign_crew": "Crews signatur",
|
||||
"sign_hint": "Signera med finger, penna eller mus",
|
||||
"sign_clear": "Radera",
|
||||
"sign_export_image": "[Signatur]",
|
||||
@@ -203,16 +218,16 @@
|
||||
"sign_badge_skipper_title_valid": "Skepparen har släppt",
|
||||
"sign_badge_skipper_title_invalid": "Skippers signatur ogiltig - innehållet har ändrats",
|
||||
"sign_classic_or_passkey": "Valfritt: klassisk signatur eller Passkey release ovan",
|
||||
"sign_crew_passkey_hint": "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_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_active": "Denna post är signerad. Ändringar i loggboken (utom foton) tar automatiskt bort skepparens och besättningens signaturer.",
|
||||
"sign_lock_notice": "Efter undertecknandet är det inte möjligt att göra ändringar i loggboksanteckningen (utom foton) utan att skepparen och crewen måste underteckna på nytt.",
|
||||
"sign_lock_active": "Denna post är signerad. Ändringar i loggboken (utom foton) tar automatiskt bort skepparens och crews signaturer.",
|
||||
"sign_lock_warning_title": "Bekräfta underskrift",
|
||||
"sign_lock_warning": "Efter undertecknandet är det inte längre möjligt att göra ändringar i loggboksanteckningen (utom foton) utan att skepparen och 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_cancel": "Avbryt",
|
||||
"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!",
|
||||
"back_to_list": "Tillbaka till tidskriftslistan",
|
||||
"save": "Spara loggbokssida",
|
||||
@@ -243,13 +258,13 @@
|
||||
"live_sails_confirm": "Logga",
|
||||
"live_sails_confirm_count": "Logga ({{count}})",
|
||||
"live_sails": "Segel: {{sails}}",
|
||||
"live_fix": "Fix",
|
||||
"live_fix_coords": "Fix {{lat}}, {{lng}}",
|
||||
"live_fix_manual_hint": "GPS ej tillgänglig. Ange latitud och longitud manuellt, eller försök igen med GPS-knappen.",
|
||||
"live_fix_gps_loading": "Hämtar GPS-position…",
|
||||
"live_fix_invalid": "Ange giltiga koordinater (latitud −90…90, longitud −180…180).",
|
||||
"live_fix_lat_placeholder": "Latitud (Lat)",
|
||||
"live_fix_lng_placeholder": "Longitud (Lng)",
|
||||
"live_position": "Position",
|
||||
"live_position_coords": "Position {{lat}}, {{lng}}",
|
||||
"live_position_manual_hint": "GPS ej tillgänglig. Ange latitud och longitud manuellt, eller försök igen med GPS-knappen.",
|
||||
"live_position_gps_loading": "Hämtar GPS-position…",
|
||||
"live_position_invalid": "Ange giltiga koordinater (latitud −90…90, longitud −180…180).",
|
||||
"live_position_lat_placeholder": "Latitud (Lat)",
|
||||
"live_position_lng_placeholder": "Longitud (Lng)",
|
||||
"live_photo_btn": "Foto (kamera)",
|
||||
"live_photo_capture_btn": "Ta foto",
|
||||
"live_photo_save_btn": "Spara",
|
||||
@@ -260,10 +275,32 @@
|
||||
"live_photo_camera_starting": "Startar kamera…",
|
||||
"live_photo_camera_denied": "Kameraåtkomst nekad eller ej tillgänglig.",
|
||||
"live_photo_camera_unavailable": "Kameran stöds inte i den här webbläsaren.",
|
||||
"live_photo_no_camera": "Ingen kamera finns på den här enheten.",
|
||||
"live_photo_error": "Foto kunde inte sparas.",
|
||||
"live_photo_entry": "Foto: {{caption}}",
|
||||
"live_photo_entry_plain": "Foto taget",
|
||||
"live_undo_photo_hint": "Foto sparat",
|
||||
"live_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_voice_transcribe_action": "Transkribera",
|
||||
"live_voice_transcribing": "Transkriberar…",
|
||||
"live_voice_transcribe_failed": "Röstanteckning sparad, men transkribering misslyckades.",
|
||||
"live_undo_voice_hint": "Röstanteckning sparad",
|
||||
"live_comment_btn": "Kommentar",
|
||||
"live_comment_placeholder": "Ange text…",
|
||||
"live_comment_confirm": "Logga",
|
||||
@@ -273,31 +310,35 @@
|
||||
"live_weather_btn": "Väder",
|
||||
"live_weather_owm_btn": "Hämta OpenWeatherMap-väder",
|
||||
"live_weather_owm_loading": "Hämtar väder…",
|
||||
"live_weather_fix_required": "Logga först en GPS-fix (Fix-knappen) för att hämta OpenWeatherMap-väder. Positionen får högst vara 6 timmar gammal.",
|
||||
"live_weather_fix_stale": "Senaste GPS-fixen är äldre än 6 timmar. Logga en ny fix innan du hämtar väder.",
|
||||
"live_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_position_stale": "Senaste positionen är äldre än 6 timmar. Logga en ny position innan du hämtar väder.",
|
||||
"live_wind_btn": "Vind",
|
||||
"live_temp_btn": "T °C",
|
||||
"live_pressure_btn": "Lufttryck",
|
||||
"live_precip_btn": "Nederbörd",
|
||||
"live_sea_state_btn": "Sjögang",
|
||||
"live_visibility_btn": "Sikt",
|
||||
"live_course_btn": "Kurs",
|
||||
"live_fuel_btn": "Diesel",
|
||||
"live_water_btn": "Vatten",
|
||||
"live_fuel_btn": "+ Diesel",
|
||||
"live_water_btn": "+ Vatten",
|
||||
"live_wind_entry": "Vind {{value}}",
|
||||
"live_temp_entry": "Temperatur {{temp}} °C",
|
||||
"live_pressure_entry": "Lufttryck {{value}} hPa",
|
||||
"live_precip_entry": "Nederbörd {{value}}",
|
||||
"live_sea_state_entry": "Sjögang {{value}}",
|
||||
"live_visibility_entry": "Sikt {{value}}",
|
||||
"live_course_entry": "Kurs {{course}}",
|
||||
"live_fuel_entry": "Diesel +{{liters}} L",
|
||||
"live_water_entry": "Vatten +{{liters}} L",
|
||||
"live_auto_position": "Auto-position",
|
||||
"live_undo_hint": "Post sparad",
|
||||
"live_undo_btn": "Ångra",
|
||||
"live_cancel": "Avbryt",
|
||||
"live_pressure_placeholder": "t.ex. 1013",
|
||||
"live_temp_placeholder": "t.ex. 18",
|
||||
"live_precip_placeholder": "t.ex. lätt regn",
|
||||
"live_sea_state_placeholder": "t.ex. 3",
|
||||
"live_visibility_placeholder": "t.ex. 10 km",
|
||||
"live_course_placeholder": "t.ex. 245",
|
||||
"live_fuel_placeholder": "Påfyllda liter",
|
||||
"live_water_placeholder": "Påfyllda liter",
|
||||
@@ -315,6 +356,7 @@
|
||||
"carry_over_tanks_yes": "Ta över",
|
||||
"carry_over_tanks_no": "Börja med 0",
|
||||
"event_title": "Kronologisk händelselogg",
|
||||
"event_creator": "Registrerad av",
|
||||
"no_events": "Inga händelser inlagda för denna resdag ännu.",
|
||||
"event_time": "Tid på dygnet",
|
||||
"event_mgk": "MgK-kurs",
|
||||
@@ -339,6 +381,12 @@
|
||||
"event_wind_direction": "Vindriktning",
|
||||
"event_wind_strength": "Vindstyrka",
|
||||
"event_sea_state": "Havets tillstånd",
|
||||
"event_visibility": "Sikt",
|
||||
"event_visibility_placeholder": "t.ex. 10 km",
|
||||
"weather_slider_unset": "—",
|
||||
"weather_slider_pressure": "{{value}} hPa",
|
||||
"weather_slider_sea_state": "Grad {{value}}",
|
||||
"weather_slider_heel": "{{value}}°",
|
||||
"event_weather": "Väder",
|
||||
"event_log": "Log (sm)",
|
||||
"event_gps": "GPS-position",
|
||||
@@ -346,7 +394,26 @@
|
||||
"event_location_placeholder": "z. t.ex. Kiel",
|
||||
"event_remarks": "Anmärkningar / incidenter",
|
||||
"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_offline": "OpenWeatherMap kräver internetanslutning. Du är offline.",
|
||||
"event_wind_pressure": "Lufttryck (hPa)",
|
||||
"event_heel": "Krängning (°)",
|
||||
"event_sails": "Segelhantering / motor",
|
||||
@@ -360,6 +427,18 @@
|
||||
"share_csv": "Aktie",
|
||||
"export_pdf": "Hämta PDF.",
|
||||
"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)",
|
||||
"photo_caption_label": "Fotobeskrivning/etikett (valfritt)",
|
||||
"photo_caption_placeholder": "t.ex. sätta segel nära hamninloppet",
|
||||
@@ -387,12 +466,12 @@
|
||||
"track_map_error": "Kartan kunde inte läsas in.",
|
||||
"exporting": "Export...",
|
||||
"share_unsupported": "Delning stöds inte på den här enheten. Filen har laddats ner istället.",
|
||||
"invite_crew": "Bjud in besättningen",
|
||||
"invite_crew": "Bjud in crewen",
|
||||
"invite_link_copied": "Länk till inbjudan kopierad till urklipp!",
|
||||
"invite_link_desc": "Dela den här länken med besättningsmedlemmar för att ge dem skrivrättigheter till loggboken.",
|
||||
"collaborators_list": "Medlemmar / Besättning",
|
||||
"invite_link_desc": "Dela den här länken med Crew-medlemmar för att ge dem skrivrättigheter till loggboken.",
|
||||
"collaborators_list": "Medlemmar / Crew",
|
||||
"revoke": "Ta bort",
|
||||
"revoke_confirm": "Är du säker på att du vill återkalla den här besättningsmedlemmens åtkomst?",
|
||||
"revoke_confirm": "Är du säker på att du vill återkalla den här Crew-medlemmens åtkomst?",
|
||||
"invite_role": "Roll",
|
||||
"invite_expires": "Länken är giltig i 48 timmar",
|
||||
"nmea_import_title": "Import NMEA log",
|
||||
@@ -438,8 +517,8 @@
|
||||
"nmea_change_engine_stop": "Engine off",
|
||||
"nmea_change_autopilot_on": "Autopilot on",
|
||||
"nmea_change_autopilot_off": "Autopilot off",
|
||||
"nmea_change_gps_lost": "GPS fix lost",
|
||||
"nmea_change_gps_regained": "GPS fix restored",
|
||||
"nmea_change_gps_lost": "GPS-position förlorad",
|
||||
"nmea_change_gps_regained": "GPS-position återställd",
|
||||
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
|
||||
"nmea_change_departure": "Departure / underway",
|
||||
"nmea_change_anchor": "Anchored / stop",
|
||||
@@ -453,7 +532,7 @@
|
||||
"new_logbook_placeholder": "Loggbokens eller båtens namn",
|
||||
"logout": "Logga ut",
|
||||
"logged_in_as": "Inloggad som {{name}}",
|
||||
"delete_confirm": "Är du säker på att du vill radera den här loggboken permanent? Alla lokala data och serverkopior kommer att förstöras.\n\nTips: Skapa en säkerhetskopia (.daagbok.json) i förväg under Inställningar → Säkerhetskopiering och återställning om du vill behålla data senare.",
|
||||
"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!",
|
||||
"loading": "Loggböckerna är fulla...",
|
||||
"status_synced": "Synkroniserad",
|
||||
@@ -461,11 +540,11 @@
|
||||
"delete_btn": "Radera loggbok",
|
||||
"section_owned": "Mina 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_hint": "Du är ägare och skeppare till denna loggbok",
|
||||
"role_crew": "Tillträde för besättningen",
|
||||
"role_crew_hint": "Inbjuden loggbok - du kan arbeta som besättning och underteckna den",
|
||||
"role_crew": "Tillträde för crewen",
|
||||
"role_crew_hint": "Inbjuden loggbok - du kan arbeta som crew och underteckna den",
|
||||
"role_read": "Endast läsning",
|
||||
"role_read_hint": "Delad loggbok - endast visning, ingen redigering",
|
||||
"open_profile": "Öppna profil för {{name}}",
|
||||
@@ -475,7 +554,7 @@
|
||||
"edit_success": "Loggboken har framgångsrikt bytt namn",
|
||||
"edit_btn": "Byt namn på",
|
||||
"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_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.",
|
||||
@@ -593,6 +672,12 @@
|
||||
"integrations_title": "Integrationer",
|
||||
"owm_key": "OpenWeatherMap API-nyckel",
|
||||
"owm_help": "Valfritt: egen OpenWeatherMap API-nyckel. Om inget anges används nyckeln på serversidan från operatörskonfigurationen.",
|
||||
"ai_title": "AI-funktioner och integritet",
|
||||
"ai_desc": "Auktorisera integrationer av artificiell intelligens för dina loggböcker.",
|
||||
"ai_help": "Genom at aktivera AI-funktioner kan appen sammanfatta dina rejsdagar och transkribera röstmemon. För att bearbeta dessa förfrågningar skickas röstdata och rejsloggar säkert och tillfälligt till OpenRouter. Inga data sparas permanent av AI-modellen.\n\nDessa molnresurser kostar pengar att driva. Om du gillar att använda dem, överväg att frivilligt stödja projektet med en donation via Ko-fi-länken i sidfoten för att hålla dem gratis och hållbara för alla.",
|
||||
"ai_enable_label": "Aktivera transkribering och sammanfattningar av rejsdagar",
|
||||
"ai_unauthorized_alert_title": "AI-funktioner är inte auktoriserade",
|
||||
"ai_unauthorized_alert_desc": "För att använda transkribering eller rejsdagsöversikter måste du auktorisera dataöverföringen till OpenRouter i din användarprofil under 'AI-funktioner och integritet'.",
|
||||
"prefs_save": "Spara",
|
||||
"prefs_saving": "Kommer att sparas...",
|
||||
"prefs_saved": "Sparade",
|
||||
@@ -600,58 +685,88 @@
|
||||
"tour_desc": "Låt dig vägledas genom de viktigaste områdena i appen igen.",
|
||||
"tour_restart": "Starta resan igen",
|
||||
"push_title": "Push-meddelanden",
|
||||
"push_desc": "Som loggboksägare får du ett meddelande när inbjudna besättningsmedlemmar synkroniserar ändringar. Inget innehåll överförs i klartext.",
|
||||
"push_enable": "Meddela oss om förändringar i besättningen",
|
||||
"push_desc": "Som loggboksägare får du ett meddelande när inbjudna Crew-medlemmar synkroniserar ändringar. Inget innehåll överförs i klartext.",
|
||||
"push_enable": "Meddela oss om förändringar i crewen",
|
||||
"push_active": "Push-meddelanden är aktiva på den här enheten.",
|
||||
"push_unsupported": "Push-meddelanden stöds inte i den här webbläsaren.",
|
||||
"push_denied_hint": "Meddelanden är blockerade. Tillåt dem i webbläsarens eller enhetens inställningar.",
|
||||
"push_ios_install_hint": "På iPhone/iPad: Lägg till app på startskärmen (iOS 16.4+) för att använda push.",
|
||||
"push_error": "Push-meddelanden kunde inte aktiveras."
|
||||
"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": "Stambesättning och skeppare",
|
||||
"subtitle": "Underhåll din personpool här – skeppare och besättning för alla loggböcker. Välj aktiv besättning per loggbok och resdag från poolen.",
|
||||
"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": "Stambesättning",
|
||||
"crew_section": "Stamm-Crew",
|
||||
"add_skipper": "Lägg till skeppare",
|
||||
"add_crew": "Lägg till besättningsmedlem",
|
||||
"add_crew": "Lägg till Crew-medlem",
|
||||
"edit_skipper": "Redigera skeppare",
|
||||
"no_skippers": "Ingen skeppare i poolen ännu.",
|
||||
"no_crew": "Inga besättningsmedlemmar i poolen ännu.",
|
||||
"no_crew": "Inga Crew-medlemmar i poolen ännu.",
|
||||
"delete_confirm": "Ta bort denna person från poolen?"
|
||||
},
|
||||
"logbook_crew": {
|
||||
"title": "Besättning för denna loggbok",
|
||||
"subtitle": "Välj skeppare och besättning för denna loggbok. Nya resdagar ärver valet som standard.",
|
||||
"loading": "Laddar besättning…",
|
||||
"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": "Besättning 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 besättning 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 besättning",
|
||||
"saved": "Loggbokbesättning sparad.",
|
||||
"selection_only_hint": "Du ser den besättning ägaren valt (delad loggbok)."
|
||||
"save": "Spara crew",
|
||||
"saved": "Loggbok-Crew sparad.",
|
||||
"selection_only_hint": "Du ser den crew ägaren valt (delad loggbok)."
|
||||
},
|
||||
"entry_crew": {
|
||||
"title": "Besättning denna resdag",
|
||||
"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": "Besättning denna dag",
|
||||
"day_crew": "Crew denna dag",
|
||||
"no_skipper": "Ingen skeppare vald",
|
||||
"no_crew": "Ingen besättning vald"
|
||||
"no_crew": "Ingen crew vald"
|
||||
},
|
||||
"crew": {
|
||||
"title": "Profiler för skeppare och besättning",
|
||||
"title": "Profiler för skeppare och crew",
|
||||
"skipper_section": "Skepparens profil",
|
||||
"skipper_read_only_hint": "Skepparens profil kan endast redigeras av loggbokens ägare.",
|
||||
"crew_section": "Besättningslista",
|
||||
"add_crew": "Lägg till besättningsmedlem",
|
||||
"edit_crew": "Redigera besättningsmedlem",
|
||||
"no_crew": "Inga besättningsmedlemmar har lagts till ännu.",
|
||||
"max_crew": "Maximalt antal på 12 besättningsmedlemmar i poolen uppnått.",
|
||||
"crew_section": "Crew-lista",
|
||||
"add_crew": "Lägg till Crew-medlem",
|
||||
"edit_crew": "Redigera Crew-medlem",
|
||||
"no_crew": "Inga Crew-medlemmar har lagts till ännu.",
|
||||
"max_crew": "Maximalt antal på 12 Crew-medlemmar i poolen uppnått.",
|
||||
"name": "Namn",
|
||||
"address": "adress",
|
||||
"birthdate": "Födelsedag",
|
||||
@@ -664,8 +779,8 @@
|
||||
"save": "Spara skeppardata",
|
||||
"save_member": "Spara medlem",
|
||||
"saved": "Skepparens profil har sparats!",
|
||||
"loading": "Besättningsfilerna är laddade...",
|
||||
"delete_confirm": "Är du säker på att du vill ta bort den här besättningsmedlemmen?"
|
||||
"loading": "Crew-filerna är laddade...",
|
||||
"delete_confirm": "Är du säker på att du vill ta bort den här Crew-medlemmen?"
|
||||
},
|
||||
"deviation": {
|
||||
"title": "Tabell för kompassavvikelse",
|
||||
@@ -684,10 +799,13 @@
|
||||
"no_key": "Ingen OpenWeatherMap API-nyckel tillgänglig. Spara din egen nyckel i användarprofilen eller kontakta operatören.",
|
||||
"weather_success": "Väderdata har hämtats framgångsrikt!",
|
||||
"weather_error": "Hämtning av väderdata misslyckades. Kontrollera API-nyckeln och anslutningen.",
|
||||
"weather_unauthorized": "Hämtning av väderdata misslyckades. API-nyckeln är ogiltig eller inte auktoriserad.",
|
||||
"weather_not_found": "Hämtning av väderdata misslyckades. Den angivna platsen eller koordinaterna hittades inte.",
|
||||
"weather_bad_request": "Hämtning av väderdata misslyckades. Ingen plats eller GPS-position angavs.",
|
||||
"weather_date_mismatch": "Väderdata kan endast hämtas för idag ({{today}}). Denna loggbokspost är daterad {{date}}.",
|
||||
"gps_error": "Ange en plats eller bestäm GPS-koordinaterna.",
|
||||
"share_title": "Aktieloggbok (skrivskyddad)",
|
||||
"share_desc": "Aktivera det här alternativet för att skapa en publik, skrivskyddad länk. Alla som har länken kan se dina resor, båtprofiler och 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_enable": "Aktivera offentlig länk",
|
||||
"share_copied": "Länk kopierad!",
|
||||
@@ -695,25 +813,25 @@
|
||||
"link_qr_hint": "Skanna QR-koden med mobilen",
|
||||
"link_qr_alt": "QR-kod för länken",
|
||||
"danger_zone_title": "Farlig zon",
|
||||
"danger_zone_desc": "Om du raderar ditt konto raderas oåterkalleligen alla dina Passkey, loggböcker, fartygsdata, besättningsprofiler, reseanteckningar och E2E-nycklar. Denna åtgärd kan inte ångras.",
|
||||
"danger_zone_desc": "Om du raderar ditt konto raderas oåterkalleligen alla dina Passkey, loggböcker, fartygsdata, Crew-profiler, reseanteckningar och E2E-nycklar. Denna åtgärd kan inte ångras.",
|
||||
"delete_account_btn": "Ta bort konto oåterkalleligt",
|
||||
"delete_account_confirm_title": "Radera konto?",
|
||||
"delete_account_confirm_desc": "Är du helt säker på att du oåterkalleligen vill radera ditt konto och alla tillhörande loggböcker och E2E-krypterade data?",
|
||||
"delete_account_confirm_yes": "Ja, radera konto och all data",
|
||||
"delete_account_confirm_no": "Avbryt",
|
||||
"delete_account_failed": "Kontot kunde inte raderas. Vänligen försök igen.",
|
||||
"delete_backup_hint": "Tips: Skapa säkerhetskopior av dina loggböcker (.daagbok.json) i inställningarna för varje loggbok innan du raderar dem.",
|
||||
"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...",
|
||||
"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_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_message": "Så snart inbjudna Crew-medlemmar synkroniserar ändringar kan du bli informerad via push. Inget loggboksinnehåll skickas i klartext.",
|
||||
"invite_push_prompt_ios_message": "Så snart Crew-medlemmar synkroniserar ändringar kan du bli informerad via push. På iPhone/iPad: Lägg till appen på startskärmen (iOS 16.4+) och aktivera sedan push i användarprofilen.",
|
||||
"invite_push_prompt_enable": "Aktivera nu",
|
||||
"invite_push_prompt_later": "Senare",
|
||||
"invite_push_prompt_success": "Push-meddelanden är aktiva på den här enheten.",
|
||||
"backup_title": "Säkerhetskopiering och återställning",
|
||||
"backup_desc": "Komplett krypterad säkerhetskopia av denna loggbok (poster, foton, GPS-spår, 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_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_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",
|
||||
@@ -725,7 +843,13 @@
|
||||
"backup_export_btn": "Ladda ner backup",
|
||||
"backup_exporting": "Säkerhetskopian skapas...",
|
||||
"backup_export_success": "Säkerhetskopia skapad ({{count}} resdagar).",
|
||||
"backup_file_label": "Säkerhetskopieringsfil (.daagbok.json)",
|
||||
"backup_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_previewing": "Check...",
|
||||
"backup_restore_btn": "Återställ",
|
||||
@@ -741,7 +865,7 @@
|
||||
"backup_new_id_confirm": "Importera säkerhetskopian som en ny loggbok med ett nytt ID?",
|
||||
"backup_stat_entries": "{{count}} Resdagar",
|
||||
"backup_stat_photos": "{{count}} Foton",
|
||||
"backup_stat_crew": "{{count}} Besättningens uppgifter",
|
||||
"backup_stat_crew": "{{count}} Crew-poster",
|
||||
"backup_stat_tracks": "{{count}} GPS-spår",
|
||||
"backup_exported_at": "Exporterad: {{date}}"
|
||||
},
|
||||
@@ -819,7 +943,7 @@
|
||||
"join_again": "Gå med igen",
|
||||
"login_or_register_hint": "Logga in eller registrera ett konto för att gå med i loggboken.",
|
||||
"or_sign_up": "ELLER REGISTRERA DIG IGEN",
|
||||
"register_crew_account": "Skapa ett nytt konto för besättningen",
|
||||
"register_crew_account": "Skapa ett nytt konto för crewen",
|
||||
"username_label": "Användarens namn",
|
||||
"create_passkey": "Skapa Passkey.",
|
||||
"switch_language_en": "Engelska",
|
||||
@@ -883,7 +1007,7 @@
|
||||
},
|
||||
"welcome_public": {
|
||||
"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": {
|
||||
"title": "Loggboksanteckningar",
|
||||
@@ -902,16 +1026,20 @@
|
||||
"body": "Ladda upp GPX-filer eller visa redan sparade rutter på kartan - inklusive avstånd och hastighet."
|
||||
},
|
||||
"nav_vessel": {
|
||||
"title": "Fartygsdata",
|
||||
"body": "Ange namn, dimensioner och tekniska data för din yacht - fyll i en gång, tillgänglig för alla resdagar."
|
||||
"title": "Fartyg för loggbok",
|
||||
"body": "Välj fartyg från flottan för denna loggbok. Hantera fartyg i användarprofilen under Flotta och besättning."
|
||||
},
|
||||
"profile_vessel_pool": {
|
||||
"title": "Fartygsflotta",
|
||||
"body": "I användarprofilen lägger du in alla fartyg – charter, egen båt m.m. Välj sedan rätt fartyg per loggbok."
|
||||
},
|
||||
"profile_crew_pool": {
|
||||
"title": "Stambesättning och skeppare",
|
||||
"body": "I användarprofilen underhåller du en personpool – flera skeppare (t.ex. charter) och besättning för alla loggböcker."
|
||||
"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": "Besättning per loggbok",
|
||||
"body": "Välj skeppare och besättning från poolen för denna loggbok. Resdagar ärver valet som standard."
|
||||
"title": "Crew per loggbok",
|
||||
"body": "Välj skeppare och crew från poolen för denna loggbok. Resdagar ärver valet som standard."
|
||||
},
|
||||
"nav_stats": {
|
||||
"title": "Kontrollpanel för statistik",
|
||||
@@ -937,7 +1065,7 @@
|
||||
},
|
||||
"seo": {
|
||||
"title": "Kapteins Daagbok - Gratis digital loggbok för båtar (reklamfri)",
|
||||
"description": "Gratis, annonsfri digital loggbok för båtar med kryptering från början till slut och Passkey-inloggning. Dokumentera resdagar, GPS-spår, 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",
|
||||
"ogImageAlt": "Kapteins Daagbok Logotyp"
|
||||
}
|
||||
|
||||
@@ -18,3 +18,62 @@ body {
|
||||
flex-direction: column;
|
||||
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 App from './App.tsx'
|
||||
import { applyAppearanceToDocument } from './services/appearance.ts'
|
||||
import { flushPendingPwaBootEvents } from './services/analytics.ts'
|
||||
import {
|
||||
installStaleAssetRecovery,
|
||||
markReloadAttempt,
|
||||
@@ -14,6 +15,15 @@ import {
|
||||
} from './services/pwaStartup.ts'
|
||||
import { redirectToPasskeyCompatibleHostIfNeeded } from './utils/passkeyHost.ts'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__KDB_MAIN_MODULE_LOADED?: boolean
|
||||
__KDB_APP_BOOTSTRAPPED?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
window.__KDB_MAIN_MODULE_LOADED = true
|
||||
|
||||
/** Stale PWA precache on localhost can shadow Vite dev modules. */
|
||||
async function clearDevServiceWorkerCaches(): Promise<void> {
|
||||
if (!import.meta.env.DEV || !('serviceWorker' in navigator)) return
|
||||
@@ -47,6 +57,10 @@ async function bootstrap(): Promise<void> {
|
||||
|
||||
applyAppearanceToDocument()
|
||||
installStaleAssetRecovery()
|
||||
flushPendingPwaBootEvents()
|
||||
window.addEventListener('load', () => {
|
||||
flushPendingPwaBootEvents()
|
||||
}, { once: true })
|
||||
await clearDevServiceWorkerCaches()
|
||||
|
||||
const startupResult = await reconcileVersionOnStartup()
|
||||
@@ -59,6 +73,17 @@ async function bootstrap(): Promise<void> {
|
||||
return
|
||||
}
|
||||
|
||||
if ('serviceWorker' in navigator && !import.meta.env.DEV) {
|
||||
navigator.serviceWorker
|
||||
.register('/sw.js', { scope: '/' })
|
||||
.then((reg) => {
|
||||
console.log('Service Worker registered successfully with scope:', reg.scope)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Service Worker registration failed:', err)
|
||||
})
|
||||
}
|
||||
|
||||
const rootEl = document.getElementById('root')
|
||||
if (!rootEl) {
|
||||
throw new Error('Missing #root element')
|
||||
@@ -69,6 +94,7 @@ async function bootstrap(): Promise<void> {
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
window.__KDB_APP_BOOTSTRAPPED = true
|
||||
}
|
||||
|
||||
void bootstrap().catch((err) => {
|
||||
@@ -76,4 +102,5 @@ void bootstrap().catch((err) => {
|
||||
renderBootstrapError(
|
||||
'Die App konnte nicht gestartet werden. Bitte neu laden oder die App vollständig beenden und erneut öffnen.',
|
||||
)
|
||||
window.__KDB_APP_BOOTSTRAPPED = false
|
||||
})
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { ApiError, apiJson } from './api.js'
|
||||
|
||||
const ADMIN_BASE = '/api/admin'
|
||||
|
||||
export interface AdminMe {
|
||||
isAdmin: boolean
|
||||
userId: string
|
||||
}
|
||||
|
||||
export interface AdminSummary {
|
||||
totalUsers: number
|
||||
totalLogbooks: number
|
||||
totalPhotos: number
|
||||
totalVoiceMemos: number
|
||||
totalGpsTracks: number
|
||||
totalCollaborations: number
|
||||
totalInvitations: number
|
||||
aiSummaryEntries: number
|
||||
}
|
||||
|
||||
export type AdminTimeBucket = 'day' | 'week' | 'month'
|
||||
|
||||
export interface AdminTimeSeriesPoint {
|
||||
date: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface AdminTimeSeriesMetric {
|
||||
metric: string
|
||||
points: AdminTimeSeriesPoint[]
|
||||
}
|
||||
|
||||
export interface AdminTimeSeriesResponse {
|
||||
bucket: AdminTimeBucket
|
||||
windowDays: number
|
||||
series: AdminTimeSeriesMetric[]
|
||||
}
|
||||
|
||||
export async function fetchAdminMe(): Promise<AdminMe> {
|
||||
return await apiJson<AdminMe>(`${ADMIN_BASE}/me`)
|
||||
}
|
||||
|
||||
/** Returns true only for users listed in server ADMIN_USER_IDS. */
|
||||
export async function checkAdminAccess(): Promise<boolean> {
|
||||
try {
|
||||
await fetchAdminMe()
|
||||
return true
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAdminSummary(): Promise<AdminSummary> {
|
||||
return await apiJson<AdminSummary>(`${ADMIN_BASE}/summary`)
|
||||
}
|
||||
|
||||
export async function fetchAdminTimeSeries(
|
||||
params: { bucket?: AdminTimeBucket; windowDays?: number } = {}
|
||||
): Promise<AdminTimeSeriesResponse> {
|
||||
const search = new URLSearchParams()
|
||||
if (params.bucket) {
|
||||
search.set('bucket', params.bucket)
|
||||
}
|
||||
if (params.windowDays && Number.isFinite(params.windowDays)) {
|
||||
search.set('window', String(params.windowDays))
|
||||
}
|
||||
const query = search.toString()
|
||||
const url = query ? `${ADMIN_BASE}/timeseries?${query}` : `${ADMIN_BASE}/timeseries`
|
||||
return await apiJson<AdminTimeSeriesResponse>(url)
|
||||
}
|
||||
|
||||
@@ -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_DISABLED: 'Push Disabled',
|
||||
FOOTER_LINK_CLICKED: 'Footer Link Clicked',
|
||||
KOFI_LINK_CLICKED: 'Ko-fi Link Clicked',
|
||||
PROFILE_OPENED: 'Profile Opened',
|
||||
PASSKEY_ADDED: 'Passkey Added',
|
||||
PASSKEY_REMOVED: 'Passkey Removed',
|
||||
@@ -40,8 +41,14 @@ export const PlausibleEvents = {
|
||||
NMEA_UPLOADED: 'NMEA Uploaded',
|
||||
LIVE_LOG_OPENED: 'Live Log Opened',
|
||||
LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged',
|
||||
LIVE_LOG_PHOTO_UPLOADED: 'Live Log Photo Uploaded',
|
||||
OWM_WEATHER_FETCHED: 'OWM Weather Fetched'
|
||||
VOICE_MEMO_UPLOADED: 'Voice Memo Uploaded',
|
||||
VOICE_MEMO_TRANSCRIBED: 'Voice Memo Transcribed',
|
||||
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
|
||||
|
||||
/** Where a successful OpenWeatherMap API call originated (no coordinates or place names). */
|
||||
@@ -50,6 +57,13 @@ export type OwmAnalyticsSource = 'live_log' | 'entry_editor' | 'entry_editor_gps
|
||||
export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents]
|
||||
|
||||
export type PlausibleEventProps = Record<string, string | number | boolean>
|
||||
type PendingPwaBootEvent = {
|
||||
name: PlausibleEventName
|
||||
props?: PlausibleEventProps
|
||||
ts?: number
|
||||
}
|
||||
|
||||
const PWA_BOOT_PENDING_EVENTS_KEY = 'pwa_boot_pending_events'
|
||||
|
||||
export function trackPlausibleEvent(name: PlausibleEventName, props?: PlausibleEventProps): void {
|
||||
if (typeof window.plausible !== 'function') return
|
||||
@@ -59,3 +73,52 @@ export function trackPlausibleEvent(name: PlausibleEventName, props?: PlausibleE
|
||||
}
|
||||
window.plausible(name)
|
||||
}
|
||||
|
||||
export function flushPendingPwaBootEvents(): number {
|
||||
if (typeof window.plausible !== 'function') return 0
|
||||
|
||||
let raw: string | null = null
|
||||
try {
|
||||
raw = sessionStorage.getItem(PWA_BOOT_PENDING_EVENTS_KEY)
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
if (!raw) return 0
|
||||
|
||||
let pending: PendingPwaBootEvent[]
|
||||
try {
|
||||
pending = JSON.parse(raw) as PendingPwaBootEvent[]
|
||||
} catch {
|
||||
try {
|
||||
sessionStorage.removeItem(PWA_BOOT_PENDING_EVENTS_KEY)
|
||||
} catch {
|
||||
/* ignore storage errors */
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
if (!Array.isArray(pending) || pending.length === 0) {
|
||||
try {
|
||||
sessionStorage.removeItem(PWA_BOOT_PENDING_EVENTS_KEY)
|
||||
} catch {
|
||||
/* ignore storage errors */
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
for (const event of pending) {
|
||||
if (!event || typeof event.name !== 'string') continue
|
||||
if (event.props && Object.keys(event.props).length > 0) {
|
||||
window.plausible(event.name, { props: event.props })
|
||||
} else {
|
||||
window.plausible(event.name)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sessionStorage.removeItem(PWA_BOOT_PENDING_EVENTS_KEY)
|
||||
} catch {
|
||||
/* ignore storage errors */
|
||||
}
|
||||
return pending.length
|
||||
}
|
||||
|
||||
@@ -10,22 +10,43 @@ export class ApiError extends Error {
|
||||
|
||||
export async function apiFetch(
|
||||
input: string,
|
||||
init: RequestInit = {}
|
||||
init: RequestInit = {},
|
||||
timeoutMs = 15000
|
||||
): Promise<Response> {
|
||||
const headers = new Headers(init.headers)
|
||||
if (init.body !== undefined && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
|
||||
return fetch(input, {
|
||||
...init,
|
||||
headers,
|
||||
credentials: 'include'
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
|
||||
|
||||
if (init.signal) {
|
||||
if (init.signal.aborted) {
|
||||
controller.abort()
|
||||
} else {
|
||||
init.signal.addEventListener('abort', () => controller.abort())
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await fetch(input, {
|
||||
...init,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
signal: controller.signal
|
||||
})
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiJson<T>(input: string, init: RequestInit = {}): Promise<T> {
|
||||
const res = await apiFetch(input, init)
|
||||
export async function apiJson<T>(
|
||||
input: string,
|
||||
init: RequestInit = {},
|
||||
timeoutMs = 15000
|
||||
): Promise<T> {
|
||||
const res = await apiFetch(input, init, timeoutMs)
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
const message =
|
||||
|
||||
@@ -64,6 +64,15 @@ export function persistSessionUserId(userId: string | undefined): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Username to use when re-unlocking after reload (active account or sole remembered user). */
|
||||
export function resolveRestoreUsername(): string | null {
|
||||
const stored = localStorage.getItem('active_username')
|
||||
if (stored) return stored
|
||||
const known = getKnownUsernames()
|
||||
if (known.length === 1) return known[0]
|
||||
return null
|
||||
}
|
||||
|
||||
export async function reauthWithPasskey(): Promise<boolean> {
|
||||
const options = await apiJson<any>(`${API_BASE}/reauth-options`, {
|
||||
method: 'POST'
|
||||
@@ -556,9 +565,15 @@ export async function deleteAccount(): Promise<boolean> {
|
||||
db.deviations.clear(),
|
||||
db.entries.clear(),
|
||||
db.photos.clear(),
|
||||
db.voiceMemos.clear(),
|
||||
db.gpsTracks.clear(),
|
||||
db.syncQueue.clear(),
|
||||
db.logbookKeys.clear()
|
||||
db.logbookKeys.clear(),
|
||||
db.personPool.clear(),
|
||||
db.vesselPool.clear(),
|
||||
db.logbookCrewSelections.clear(),
|
||||
db.logbookVesselSelections.clear(),
|
||||
db.userSyncQueue.clear()
|
||||
])
|
||||
|
||||
// Wipe localStorage and session variables
|
||||
|
||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
hasUnlockedLocalCrypto,
|
||||
hasUnlockedLocalSession,
|
||||
resolveRestoreUsername,
|
||||
setActiveMasterKey
|
||||
} from './auth.js'
|
||||
|
||||
@@ -33,6 +34,28 @@ describe('local session unlock checks', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveRestoreUsername', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('prefers active_username from storage', () => {
|
||||
localStorage.setItem('active_username', 'captain')
|
||||
localStorage.setItem('daagbox_known_users', JSON.stringify(['other']))
|
||||
expect(resolveRestoreUsername()).toBe('captain')
|
||||
})
|
||||
|
||||
it('falls back to a single remembered user', () => {
|
||||
localStorage.setItem('daagbox_known_users', JSON.stringify(['solo']))
|
||||
expect(resolveRestoreUsername()).toBe('solo')
|
||||
})
|
||||
|
||||
it('returns null when multiple users and no active username', () => {
|
||||
localStorage.setItem('daagbox_known_users', JSON.stringify(['alpha', 'beta']))
|
||||
expect(resolveRestoreUsername()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('persistSessionUserId', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
|
||||
@@ -37,22 +37,17 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
throw new Error('Encryption key not found. User must log in.')
|
||||
}
|
||||
|
||||
// 1. Fetch Yacht details
|
||||
const yachtRecord = await db.yachts.get(logbookId);
|
||||
if (yachtRecord) {
|
||||
try {
|
||||
const yacht = await decryptJson(yachtRecord.encryptedData, yachtRecord.iv, yachtRecord.tag, masterKey);
|
||||
yachtName = yacht.name || '';
|
||||
homePort = yacht.port || '';
|
||||
owner = yacht.owner || '';
|
||||
charter = yacht.charter || '';
|
||||
registration = yacht.registration || '';
|
||||
callsign = yacht.callsign || '';
|
||||
atis = yacht.atis || '';
|
||||
mmsi = yacht.mmsi || '';
|
||||
} catch (e) {
|
||||
console.error('Failed to decrypt yacht details for CSV:', e);
|
||||
}
|
||||
const { resolveVesselForLogbook } = await import('./resolveVessel.js')
|
||||
const yacht = await resolveVesselForLogbook(logbookId)
|
||||
if (yacht) {
|
||||
yachtName = yacht.name || ''
|
||||
homePort = yacht.homePort || ''
|
||||
owner = yacht.owner || ''
|
||||
charter = yacht.charterCompany || ''
|
||||
registration = yacht.registrationNumber || ''
|
||||
callsign = yacht.callSign || ''
|
||||
atis = yacht.atis || ''
|
||||
mmsi = yacht.mmsi || ''
|
||||
}
|
||||
|
||||
// 2. Fetch logbook entries
|
||||
@@ -79,11 +74,11 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
|
||||
// Headers matching the requested event fields & metadata
|
||||
const headers = [
|
||||
'Date', 'Day of Travel', 'Departure Port', 'Destination Port',
|
||||
'Date', 'Day of Travel', 'Departure Port', 'Destination Port', 'AI Summary',
|
||||
'Skipper Signature', 'Crew Signature',
|
||||
'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)', 'Motor Hours (h)',
|
||||
'Event Time', 'MgK Course', 'RwK Course',
|
||||
'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State',
|
||||
'Event Time', 'Event Creator', 'MgK Course', 'RwK Course',
|
||||
'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State', 'Visibility',
|
||||
'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)',
|
||||
'Latitude', 'Longitude', 'Remarks',
|
||||
'Freshwater Morning (L)', 'Freshwater Refilled (L)', 'Freshwater Evening (L)', 'Freshwater Consumption (L)',
|
||||
@@ -125,17 +120,19 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
const fuelE = entry.fuel?.evening ?? '';
|
||||
const fuelCons = entry.fuel?.consumption ?? '';
|
||||
const greywaterLevel = entry.greywater?.level ?? '';
|
||||
const aiSummary = entry.aiSummary ?? '';
|
||||
|
||||
const crewSnapshots = (entry.crewSnapshotsById as Record<string, any>) || {};
|
||||
const eventsList = entry.events || [];
|
||||
if (eventsList.length === 0) {
|
||||
// Create one row even if there are no events for the day
|
||||
rows.push([
|
||||
dateVal, travelDay, dep, dest,
|
||||
dateVal, travelDay, dep, dest, aiSummary,
|
||||
signS, signC,
|
||||
trackDist, trackMax, trackAvg, motorH,
|
||||
'', '', '',
|
||||
'', '', '', '',
|
||||
'', '', '', '', '',
|
||||
'', '', '', '', '',
|
||||
'', '', '',
|
||||
fwM, fwR, fwE, fwCons,
|
||||
fuelM, fuelR, fuelE, fuelCons,
|
||||
@@ -146,12 +143,23 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
// Sort events chronologically by time
|
||||
const sortedEvents = sortLogEventsByTime(eventsList);
|
||||
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([
|
||||
dateVal, travelDay, dep, dest,
|
||||
dateVal, travelDay, dep, dest, aiSummary,
|
||||
signS, signC,
|
||||
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.visibility || '',
|
||||
ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '',
|
||||
ev.gpsLat || '', ev.gpsLng || '', ev.remarks || '',
|
||||
fwM, fwR, fwE, fwCons,
|
||||
|
||||
@@ -35,6 +35,14 @@ export interface LocalDeviation {
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface EntryListCache {
|
||||
date: string
|
||||
dayOfTravel: string
|
||||
departure: string
|
||||
destination: string
|
||||
skipperSignStatus: 'none' | 'valid' | 'invalid'
|
||||
}
|
||||
|
||||
export interface LocalEntry {
|
||||
payloadId: string
|
||||
logbookId: string
|
||||
@@ -42,6 +50,8 @@ export interface LocalEntry {
|
||||
iv: string
|
||||
tag: string
|
||||
updatedAt: string
|
||||
/** Plaintext list fields — avoids full decrypt when opening the journal list. */
|
||||
listCache?: EntryListCache
|
||||
}
|
||||
|
||||
export interface LocalPhoto {
|
||||
@@ -55,6 +65,16 @@ export interface LocalPhoto {
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface LocalVoiceMemo {
|
||||
payloadId: string
|
||||
entryId: string
|
||||
logbookId: string
|
||||
encryptedData: string
|
||||
iv: string
|
||||
tag: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface LocalGpsTrack {
|
||||
entryId: string // one track per daily journal entry
|
||||
logbookId: string
|
||||
@@ -88,6 +108,14 @@ export interface LocalPerson {
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface LocalVessel {
|
||||
payloadId: string
|
||||
encryptedData: string
|
||||
iv: string
|
||||
tag: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface LocalLogbookCrewSelection {
|
||||
logbookId: string
|
||||
encryptedData: string
|
||||
@@ -96,10 +124,28 @@ export interface LocalLogbookCrewSelection {
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface LocalLogbookVesselSelection {
|
||||
logbookId: string
|
||||
encryptedData: string
|
||||
iv: string
|
||||
tag: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface SyncQueueItem {
|
||||
id?: number
|
||||
action: 'create' | 'update' | 'delete'
|
||||
type: 'yacht' | 'crew' | 'deviation' | 'entry' | 'logbook' | 'photo' | 'gpsTrack' | 'logbookCrew'
|
||||
type:
|
||||
| 'yacht'
|
||||
| 'crew'
|
||||
| 'deviation'
|
||||
| 'entry'
|
||||
| 'logbook'
|
||||
| 'photo'
|
||||
| 'voiceMemo'
|
||||
| 'gpsTrack'
|
||||
| 'logbookCrew'
|
||||
| 'logbookVessel'
|
||||
payloadId: string // payloadId or logbookId depending on the type
|
||||
logbookId: string
|
||||
data: string // JSON representation of the local record
|
||||
@@ -109,7 +155,7 @@ export interface SyncQueueItem {
|
||||
export interface UserSyncQueueItem {
|
||||
id?: number
|
||||
action: 'create' | 'update' | 'delete'
|
||||
type: 'person'
|
||||
type: 'person' | 'vessel'
|
||||
payloadId: string
|
||||
data: string
|
||||
updatedAt: string
|
||||
@@ -131,11 +177,14 @@ class DaagboxDatabase extends Dexie {
|
||||
deviations!: Table<LocalDeviation>
|
||||
entries!: Table<LocalEntry>
|
||||
photos!: Table<LocalPhoto>
|
||||
voiceMemos!: Table<LocalVoiceMemo>
|
||||
gpsTracks!: Table<LocalGpsTrack>
|
||||
nmeaArchives!: Table<LocalNmeaArchive>
|
||||
logbookKeys!: Table<LocalLogbookKey>
|
||||
personPool!: Table<LocalPerson>
|
||||
vesselPool!: Table<LocalVessel>
|
||||
logbookCrewSelections!: Table<LocalLogbookCrewSelection>
|
||||
logbookVesselSelections!: Table<LocalLogbookVesselSelection>
|
||||
syncQueue!: Table<SyncQueueItem>
|
||||
userSyncQueue!: Table<UserSyncQueueItem>
|
||||
entryDrafts!: Table<EntryDraftRecord, [string, string]>
|
||||
@@ -234,6 +283,43 @@ class DaagboxDatabase extends Dexie {
|
||||
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,6 +4,7 @@ import { getActiveMasterKey } from './auth.js'
|
||||
import { getLogbookKey } from './logbookKeys.js'
|
||||
import { encryptJson } from './crypto.js'
|
||||
import { syncLogbook } from './sync.js'
|
||||
import { putEntryRecord } from '../utils/entryListCache.js'
|
||||
import { syncPersonPool } from './personPoolSync.js'
|
||||
import i18n from '../i18n/index.js'
|
||||
import type { PersonData } from '../types/person.js'
|
||||
@@ -35,14 +36,17 @@ async function putEncryptedRecord(
|
||||
const encrypted = await encryptJson(data, key)
|
||||
|
||||
if (type === 'entry') {
|
||||
await db.entries.put({
|
||||
payloadId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
await putEntryRecord(
|
||||
{
|
||||
payloadId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
},
|
||||
data as Record<string, unknown>
|
||||
)
|
||||
} else if (type === 'yacht') {
|
||||
await db.yachts.put({
|
||||
logbookId,
|
||||
|
||||
@@ -17,6 +17,7 @@ const PUBLIC_DEMO_ENTRY_IDS = [
|
||||
] as const
|
||||
|
||||
export const PUBLIC_DEMO_SKIPPER_ID = 'skipper'
|
||||
export const PUBLIC_DEMO_VESSEL_ID = 'demo-vessel-1'
|
||||
const PUBLIC_DEMO_CREW_MEMBER_ID = 'a0000001-0000-4000-8000-000000000010'
|
||||
|
||||
export interface DemoDaySpec {
|
||||
@@ -50,9 +51,19 @@ export interface DemoCrewRecord {
|
||||
}
|
||||
}
|
||||
|
||||
export interface DemoVesselRecord {
|
||||
payloadId: string
|
||||
data: Record<string, unknown> & { name: string }
|
||||
}
|
||||
|
||||
export interface PublicDemoFixture {
|
||||
title: string
|
||||
yacht: Record<string, unknown>
|
||||
vesselPool: DemoVesselRecord[]
|
||||
logbookVesselSelection: {
|
||||
activeVesselId: string | null
|
||||
vesselSnapshot: Record<string, unknown> | null
|
||||
}
|
||||
/** @deprecated legacy share payload */
|
||||
crews: DemoCrewRecord[]
|
||||
personPool: DemoCrewRecord[]
|
||||
@@ -238,6 +249,24 @@ 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')
|
||||
@@ -255,6 +284,8 @@ function buildDemoLogbookCrewSelection(pool: DemoCrewRecord[]) {
|
||||
export function buildPublicDemoFixture(): PublicDemoFixture {
|
||||
const title = i18n.t('demo.logbook_title')
|
||||
const yacht = buildDemoYachtData()
|
||||
const vesselPool = buildDemoVesselPool(yacht)
|
||||
const logbookVesselSelection = buildDemoLogbookVesselSelection(yacht)
|
||||
const personPool = buildDemoPersonPool()
|
||||
const crews = personPool
|
||||
const logbookCrewSelection = buildDemoLogbookCrewSelection(personPool)
|
||||
@@ -310,6 +341,8 @@ export function buildPublicDemoFixture(): PublicDemoFixture {
|
||||
return {
|
||||
title,
|
||||
yacht,
|
||||
vesselPool,
|
||||
logbookVesselSelection,
|
||||
crews,
|
||||
personPool,
|
||||
logbookCrewSelection,
|
||||
|
||||
@@ -283,6 +283,7 @@ export async function deleteLocalLogbookCache(id: string): Promise<void> {
|
||||
await db.deviations.where({ logbookId: id }).delete()
|
||||
await db.entries.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.syncQueue.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 { getActiveMasterKey } from './auth.js'
|
||||
import {
|
||||
@@ -9,89 +10,54 @@ import { decryptLogbookTitle, deleteLocalLogbookCache } from './logbook.js'
|
||||
import { ensureLogbookKey, getLogbookKey, saveLogbookKey } from './logbookKeys.js'
|
||||
import { syncLogbook } from './sync.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 const BACKUP_VERSION = 1 as const
|
||||
|
||||
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 { BACKUP_FORMAT, BACKUP_VERSION }
|
||||
export type { BackupExportProgress, BackupManifestCounts, BackupManifestV2 }
|
||||
|
||||
export interface LogbookBackupPreview {
|
||||
title: string
|
||||
exportedAt: 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> {
|
||||
const encoder = new TextEncoder()
|
||||
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(
|
||||
'raw',
|
||||
@@ -120,26 +86,17 @@ async function wrapLogbookKey(logbookKey: ArrayBuffer, passphrase: string) {
|
||||
return encryptBuffer(logbookKey, key)
|
||||
}
|
||||
|
||||
async function unwrapLogbookKey(
|
||||
wrapped: LogbookBackupFile['logbookKey'],
|
||||
async function unwrapLogbookKeyFromEnc(
|
||||
keyEnc: Uint8Array,
|
||||
passphrase: string
|
||||
): Promise<ArrayBuffer> {
|
||||
const key = await deriveBackupPassphraseKey(passphrase)
|
||||
return decryptBuffer(wrapped.ciphertext, wrapped.iv, wrapped.tag, key)
|
||||
}
|
||||
|
||||
function isBackupFile(value: unknown): value is LogbookBackupFile {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
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
|
||||
)
|
||||
try {
|
||||
const fields = dexieFieldsFromEncBytes(keyEnc)
|
||||
const cryptoKey = await deriveBackupPassphraseKey(passphrase)
|
||||
return decryptBuffer(fields.encryptedData, fields.iv, fields.tag, cryptoKey)
|
||||
} catch {
|
||||
throw new Error('BACKUP_WRONG_PASSPHRASE')
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
logbookId: string,
|
||||
encryptedTitle: string,
|
||||
logbookKey: ArrayBuffer,
|
||||
payloads: LogbookBackupFile['payloads']
|
||||
manifest: BackupManifestV2,
|
||||
files: Record<string, Uint8Array>
|
||||
): Promise<void> {
|
||||
const masterKey = getActiveMasterKey()
|
||||
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({
|
||||
action: 'update',
|
||||
type: 'yacht',
|
||||
payloadId: logbookId,
|
||||
logbookId,
|
||||
data: encryptedPayloadData(
|
||||
payloads.yacht.encryptedData,
|
||||
payloads.yacht.iv,
|
||||
payloads.yacht.tag
|
||||
),
|
||||
updatedAt: payloads.yacht.updatedAt
|
||||
data: encryptedPayloadData(yacht.encryptedData, yacht.iv, yacht.tag),
|
||||
updatedAt: now
|
||||
})
|
||||
}
|
||||
|
||||
if (payloads.deviation) {
|
||||
const deviation = readFields(manifest.files.deviation)
|
||||
if (deviation) {
|
||||
items.push({
|
||||
action: 'update',
|
||||
type: 'deviation',
|
||||
payloadId: logbookId,
|
||||
logbookId,
|
||||
data: encryptedPayloadData(
|
||||
payloads.deviation.encryptedData,
|
||||
payloads.deviation.iv,
|
||||
payloads.deviation.tag
|
||||
),
|
||||
updatedAt: payloads.deviation.updatedAt
|
||||
data: encryptedPayloadData(deviation.encryptedData, deviation.iv, deviation.tag),
|
||||
updatedAt: now
|
||||
})
|
||||
}
|
||||
|
||||
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({
|
||||
action: 'create',
|
||||
type: 'crew',
|
||||
payloadId: crew.payloadId,
|
||||
logbookId,
|
||||
data: encryptedPayloadData(crew.encryptedData, crew.iv, crew.tag),
|
||||
data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag),
|
||||
updatedAt: crew.updatedAt
|
||||
})
|
||||
}
|
||||
|
||||
for (const entry of payloads.entries) {
|
||||
for (const entry of manifest.files.entries) {
|
||||
const f = readFields(entry.path)
|
||||
items.push({
|
||||
action: 'create',
|
||||
type: 'entry',
|
||||
payloadId: entry.payloadId,
|
||||
logbookId,
|
||||
data: encryptedPayloadData(entry.encryptedData, entry.iv, entry.tag),
|
||||
data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag),
|
||||
updatedAt: entry.updatedAt
|
||||
})
|
||||
}
|
||||
|
||||
for (const photo of payloads.photos) {
|
||||
for (const photo of manifest.files.photos) {
|
||||
const f = readFields(photo.path)
|
||||
items.push({
|
||||
action: 'create',
|
||||
type: 'photo',
|
||||
payloadId: photo.payloadId,
|
||||
logbookId,
|
||||
data: encryptedPayloadData(photo.encryptedData, photo.iv, photo.tag, {
|
||||
data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag, {
|
||||
entryId: photo.entryId
|
||||
}),
|
||||
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({
|
||||
action: 'create',
|
||||
type: 'gpsTrack',
|
||||
payloadId: track.entryId,
|
||||
logbookId,
|
||||
data: encryptedPayloadData(track.encryptedData, track.iv, track.tag),
|
||||
data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag),
|
||||
updatedAt: track.updatedAt
|
||||
})
|
||||
}
|
||||
@@ -357,101 +275,190 @@ async function queueRestoredLogbookForSync(
|
||||
|
||||
async function writeBackupToDexie(
|
||||
logbookId: string,
|
||||
backup: LogbookBackupFile,
|
||||
logbookKey: ArrayBuffer
|
||||
logbookMeta: LogbookMetaJson,
|
||||
logbookKey: ArrayBuffer,
|
||||
manifest: BackupManifestV2,
|
||||
files: Record<string, Uint8Array>
|
||||
): Promise<void> {
|
||||
const { logbook, payloads } = backup
|
||||
|
||||
await db.logbooks.put({
|
||||
id: logbookId,
|
||||
encryptedTitle: logbook.encryptedTitle,
|
||||
updatedAt: logbook.updatedAt,
|
||||
encryptedTitle: logbookMeta.encryptedTitle,
|
||||
updatedAt: logbookMeta.updatedAt,
|
||||
isSynced: 0,
|
||||
isShared: 0,
|
||||
isDemo: logbook.isDemo ? 1 : 0
|
||||
isDemo: logbookMeta.isDemo ? 1 : 0
|
||||
})
|
||||
|
||||
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({
|
||||
logbookId,
|
||||
encryptedData: payloads.yacht.encryptedData,
|
||||
iv: payloads.yacht.iv,
|
||||
tag: payloads.yacht.tag,
|
||||
updatedAt: payloads.yacht.updatedAt
|
||||
encryptedData: yacht.encryptedData,
|
||||
iv: yacht.iv,
|
||||
tag: yacht.tag,
|
||||
updatedAt: logbookMeta.updatedAt
|
||||
})
|
||||
}
|
||||
|
||||
if (payloads.deviation) {
|
||||
const deviation = readFields(manifest.files.deviation)
|
||||
if (deviation) {
|
||||
await db.deviations.put({
|
||||
logbookId,
|
||||
encryptedData: payloads.deviation.encryptedData,
|
||||
iv: payloads.deviation.iv,
|
||||
tag: payloads.deviation.tag,
|
||||
updatedAt: payloads.deviation.updatedAt
|
||||
encryptedData: deviation.encryptedData,
|
||||
iv: deviation.iv,
|
||||
tag: deviation.tag,
|
||||
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(
|
||||
payloads.crews.map((c) => ({
|
||||
payloadId: c.payloadId,
|
||||
logbookId,
|
||||
encryptedData: c.encryptedData,
|
||||
iv: c.iv,
|
||||
tag: c.tag,
|
||||
updatedAt: c.updatedAt
|
||||
}))
|
||||
manifest.files.crews.map((c) => {
|
||||
const f = dexieFieldsFromEncBytes(readBinaryFile(files, c.path))
|
||||
return {
|
||||
payloadId: c.payloadId,
|
||||
logbookId,
|
||||
encryptedData: f.encryptedData,
|
||||
iv: f.iv,
|
||||
tag: f.tag,
|
||||
updatedAt: c.updatedAt
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (payloads.entries.length > 0) {
|
||||
if (manifest.files.entries.length > 0) {
|
||||
await db.entries.bulkPut(
|
||||
payloads.entries.map((e) => ({
|
||||
payloadId: e.payloadId,
|
||||
logbookId,
|
||||
encryptedData: e.encryptedData,
|
||||
iv: e.iv,
|
||||
tag: e.tag,
|
||||
updatedAt: e.updatedAt
|
||||
}))
|
||||
manifest.files.entries.map((e) => {
|
||||
const f = dexieFieldsFromEncBytes(readBinaryFile(files, e.path))
|
||||
return {
|
||||
payloadId: e.payloadId,
|
||||
logbookId,
|
||||
encryptedData: f.encryptedData,
|
||||
iv: f.iv,
|
||||
tag: f.tag,
|
||||
updatedAt: e.updatedAt
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (payloads.photos.length > 0) {
|
||||
if (manifest.files.photos.length > 0) {
|
||||
await db.photos.bulkPut(
|
||||
payloads.photos.map((p) => ({
|
||||
payloadId: p.payloadId,
|
||||
entryId: p.entryId,
|
||||
logbookId,
|
||||
encryptedData: p.encryptedData,
|
||||
iv: p.iv,
|
||||
tag: p.tag,
|
||||
caption: '',
|
||||
updatedAt: p.updatedAt
|
||||
}))
|
||||
manifest.files.photos.map((p) => {
|
||||
const f = dexieFieldsFromEncBytes(readBinaryFile(files, p.path))
|
||||
return {
|
||||
payloadId: p.payloadId,
|
||||
entryId: p.entryId,
|
||||
logbookId,
|
||||
encryptedData: f.encryptedData,
|
||||
iv: f.iv,
|
||||
tag: f.tag,
|
||||
caption: '',
|
||||
updatedAt: p.updatedAt
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (payloads.gpsTracks.length > 0) {
|
||||
await db.gpsTracks.bulkPut(
|
||||
payloads.gpsTracks.map((t) => ({
|
||||
entryId: t.entryId,
|
||||
logbookId,
|
||||
encryptedData: t.encryptedData,
|
||||
iv: t.iv,
|
||||
tag: t.tag,
|
||||
updatedAt: t.updatedAt
|
||||
}))
|
||||
if (manifest.files.voiceMemos.length > 0) {
|
||||
await db.voiceMemos.bulkPut(
|
||||
manifest.files.voiceMemos.map((v) => {
|
||||
const f = dexieFieldsFromEncBytes(readBinaryFile(files, v.path))
|
||||
return {
|
||||
payloadId: v.payloadId,
|
||||
entryId: v.entryId,
|
||||
logbookId,
|
||||
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(
|
||||
logbookId: string,
|
||||
passphrase: string
|
||||
): Promise<{ blob: Blob; filename: string; backup: LogbookBackupFile }> {
|
||||
passphrase: string,
|
||||
options: ExportLogbookBackupOptions = {}
|
||||
): Promise<{ blob: Blob; filename: string; manifest: BackupManifestV2 }> {
|
||||
if (!passphrase.trim() || passphrase.length < 8) {
|
||||
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 payloads = await collectLogbookPayloads(logbookId)
|
||||
const wrappedKey = await wrapLogbookKey(logbookKey, passphrase)
|
||||
const wrapped = await wrapLogbookKey(logbookKey, passphrase)
|
||||
const keyEnc = encBytesFromDexieFields({
|
||||
encryptedData: wrapped.ciphertext,
|
||||
iv: wrapped.iv,
|
||||
tag: wrapped.tag
|
||||
})
|
||||
|
||||
const backup: LogbookBackupFile = {
|
||||
format: BACKUP_FORMAT,
|
||||
version: BACKUP_VERSION,
|
||||
const { zipBytes, manifest } = buildArchiveFromCollected(collected, keyEnc, {
|
||||
exportedAt: new Date().toISOString(),
|
||||
logbook: {
|
||||
id: logbook.id,
|
||||
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
|
||||
}
|
||||
}
|
||||
appVersion: getAppVersion(),
|
||||
onProgress: options.onProgress
|
||||
})
|
||||
|
||||
const title = await decryptLogbookTitle(logbookId, logbook.encryptedTitle)
|
||||
const safeTitle = title.replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-').slice(0, 40) || 'logbook'
|
||||
const datePart = new Date().toISOString().slice(0, 10)
|
||||
const filename = `${safeTitle}-${datePart}.daagbok.json`
|
||||
const blob = new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' })
|
||||
const filename = `${safeTitle}-${datePart}.daagbok`
|
||||
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> {
|
||||
const text = await file.text()
|
||||
let parsed: unknown
|
||||
function detectLegacyJsonV1(text: string): boolean {
|
||||
const trimmed = text.trimStart()
|
||||
if (!trimmed.startsWith('{')) return false
|
||||
try {
|
||||
parsed = JSON.parse(text)
|
||||
const parsed = JSON.parse(trimmed) as { format?: string; version?: number }
|
||||
return parsed.format === BACKUP_FORMAT && parsed.version === 1
|
||||
} 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)) {
|
||||
throw new Error('BACKUP_INVALID_FORMAT')
|
||||
}
|
||||
|
||||
return parsed
|
||||
const files = unzipArchive(bytes)
|
||||
const manifest = readManifestFromArchive(files)
|
||||
return { manifest, files }
|
||||
}
|
||||
|
||||
export async function previewLogbookBackup(
|
||||
backup: LogbookBackupFile,
|
||||
backup: ParsedLogbookBackup,
|
||||
passphrase: string
|
||||
): Promise<LogbookBackupPreview> {
|
||||
const logbookKey = await unwrapLogbookKey(backup.logbookKey, passphrase)
|
||||
const parsed = JSON.parse(backup.logbook.encryptedTitle)
|
||||
const title = await decryptJson(parsed.ciphertext, parsed.iv, parsed.tag, logbookKey)
|
||||
const logbookKey = await unwrapLogbookKeyFromEnc(
|
||||
readBinaryFile(backup.files, backup.manifest.files.key),
|
||||
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 {
|
||||
title,
|
||||
exportedAt: backup.exportedAt,
|
||||
sourceLogbookId: backup.logbook.id,
|
||||
counts: backup.counts
|
||||
exportedAt: backup.manifest.exportedAt,
|
||||
sourceLogbookId: backup.manifest.logbookId,
|
||||
counts: backup.manifest.counts,
|
||||
totalUncompressedBytes: backup.manifest.totalUncompressedBytes
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,7 +561,7 @@ export interface RestoreLogbookOptions {
|
||||
}
|
||||
|
||||
export async function restoreLogbookBackup(
|
||||
backup: LogbookBackupFile,
|
||||
backup: ParsedLogbookBackup,
|
||||
passphrase: string,
|
||||
options: RestoreLogbookOptions = {}
|
||||
): Promise<{ logbookId: string; title: string }> {
|
||||
@@ -548,16 +569,22 @@ export async function restoreLogbookBackup(
|
||||
throw new Error('BACKUP_NOT_AUTHENTICATED')
|
||||
}
|
||||
|
||||
const logbookKey = await unwrapLogbookKey(backup.logbookKey, passphrase)
|
||||
const parsedTitle = JSON.parse(backup.logbook.encryptedTitle)
|
||||
const title = await decryptJson(
|
||||
parsedTitle.ciphertext,
|
||||
parsedTitle.iv,
|
||||
parsedTitle.tag,
|
||||
logbookKey
|
||||
const logbookKey = await unwrapLogbookKeyFromEnc(
|
||||
readBinaryFile(backup.files, backup.manifest.files.key),
|
||||
passphrase
|
||||
)
|
||||
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)
|
||||
|
||||
if (existing && !options.overwrite && !options.assignNewId) {
|
||||
@@ -568,18 +595,29 @@ export async function restoreLogbookBackup(
|
||||
await deleteLocalLogbookCache(targetId)
|
||||
}
|
||||
|
||||
let prepared = backup
|
||||
if (options.assignNewId || (existing && !options.overwrite)) {
|
||||
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(
|
||||
targetId,
|
||||
prepared.logbook.encryptedTitle,
|
||||
finalMeta.encryptedTitle,
|
||||
logbookKey,
|
||||
prepared.payloads
|
||||
prepared.manifest,
|
||||
prepared.files
|
||||
)
|
||||
|
||||
if (navigator.onLine) {
|
||||
@@ -599,3 +637,14 @@ export function downloadBackupBlob(blob: Blob, filename: string): void {
|
||||
anchor.click()
|
||||
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
|
||||
}
|
||||
@@ -91,6 +91,7 @@ export function clearLogbookKeysCache() {
|
||||
export async function ensureLogbookKey(logbookId: string): Promise<ArrayBuffer> {
|
||||
const localLb = await db.logbooks.get(logbookId)
|
||||
const encryptedTitle = localLb ? localLb.encryptedTitle : ''
|
||||
const isShared = localLb?.isShared === 1
|
||||
const masterKey = getActiveMasterKey()
|
||||
|
||||
let key = await getLogbookKey(logbookId)
|
||||
@@ -103,6 +104,11 @@ export async function ensureLogbookKey(logbookId: string): Promise<ArrayBuffer>
|
||||
// Key works, return it
|
||||
return key
|
||||
} 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)...')
|
||||
try {
|
||||
const parsed = JSON.parse(encryptedTitle)
|
||||
@@ -145,6 +151,12 @@ export async function ensureLogbookKey(logbookId: string): Promise<ArrayBuffer>
|
||||
|
||||
// If no logbook key exists yet
|
||||
if (!key) {
|
||||
if (isShared) {
|
||||
throw new Error(
|
||||
'Shared logbook encryption key not found. Please go online and refresh your logbooks.'
|
||||
)
|
||||
}
|
||||
|
||||
if (encryptedTitle && masterKey) {
|
||||
try {
|
||||
// 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 { DEFAULT_NMEA_DETECTION_CONFIG } from './nmeaTypes.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) {
|
||||
const last = events[events.length - 1]
|
||||
if (last && last.type === event.type && event.timestamp - last.timestamp < minGapMs) return
|
||||
@@ -64,7 +69,7 @@ export function detectNmeaChanges(
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'medium',
|
||||
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
|
||||
}, config.dedupeWindowMs)
|
||||
}
|
||||
@@ -79,7 +84,7 @@ export function detectNmeaChanges(
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'medium',
|
||||
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
|
||||
}, config.dedupeWindowMs)
|
||||
}
|
||||
@@ -95,7 +100,7 @@ export function detectNmeaChanges(
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'high',
|
||||
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
|
||||
}, config.dedupeWindowMs)
|
||||
}
|
||||
@@ -156,7 +161,7 @@ export function detectNmeaChanges(
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'medium',
|
||||
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
|
||||
}, config.dedupeWindowMs)
|
||||
}
|
||||
@@ -200,7 +205,7 @@ export function detectNmeaChanges(
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'low',
|
||||
summaryKey: 'logs.nmea_change_speed',
|
||||
summaryParams: { from: lastSog.toFixed(1), to: sog.toFixed(1) },
|
||||
summaryParams: { from: formatNmeaDecimal(lastSog), to: formatNmeaDecimal(sog) },
|
||||
data: p
|
||||
}, config.dedupeWindowMs)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { TFunction } from 'i18next'
|
||||
import type { LogEventPayload } from '../../utils/logEntryPayload.js'
|
||||
import { normalizeLogEvent } from '../../utils/logEntryPayload.js'
|
||||
import { formatCourseAngle } from '../../utils/courseAngle.js'
|
||||
import { formatAppDecimal, formatCanonicalCoordinate } from '../../utils/numberFormat.js'
|
||||
import { degreesToCardinal } from '../../utils/courseAngle.js'
|
||||
import type {
|
||||
NmeaChangeEvent,
|
||||
@@ -33,9 +34,12 @@ function pointToLogEvent(
|
||||
windDirection: windDir,
|
||||
windStrength: point.windSpeedKnots != null ? String(point.windSpeedKnots) : '',
|
||||
windPressure: point.pressureHpa != null ? String(Math.round(point.pressureHpa)) : '',
|
||||
gpsLat: point.lat != null ? point.lat.toFixed(6) : '',
|
||||
gpsLng: point.lng != null ? point.lng.toFixed(6) : '',
|
||||
logReading: point.logDistanceNm != null ? point.logDistanceNm.toFixed(2) : '',
|
||||
gpsLat: point.lat != null ? formatCanonicalCoordinate(point.lat) : '',
|
||||
gpsLng: point.lng != null ? formatCanonicalCoordinate(point.lng) : '',
|
||||
logReading:
|
||||
point.logDistanceNm != null
|
||||
? formatAppDecimal(point.logDistanceNm, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
: '',
|
||||
sailsOrMotor,
|
||||
remarks
|
||||
})
|
||||
@@ -51,7 +55,11 @@ function buildRemarks(change: NmeaChangeEvent, t: TFunction): string {
|
||||
const parts: string[] = []
|
||||
parts.push(t(change.summaryKey, change.summaryParams ?? {}))
|
||||
if (change.data?.depthM != null) {
|
||||
parts.push(t('logs.nmea_remark_depth', { depth: change.data.depthM.toFixed(1) }))
|
||||
parts.push(
|
||||
t('logs.nmea_remark_depth', {
|
||||
depth: formatAppDecimal(change.data.depthM, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
||||
})
|
||||
)
|
||||
}
|
||||
if (change.confidence === 'low') {
|
||||
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> {
|
||||
let yachtName = '', homePort = '', registration = '', callsign = '', atis = '', mmsi = '';
|
||||
let yachtName = '', owner = '', homePort = '', registration = '', callsign = '', atis = '', mmsi = '';
|
||||
let entry: any = null;
|
||||
|
||||
if (preloadedData) {
|
||||
const yacht = preloadedData.yacht || {};
|
||||
yachtName = yacht.name || '';
|
||||
owner = yacht.owner || '';
|
||||
homePort = yacht.port || '';
|
||||
registration = yacht.registrationNumber || yacht.registration || '';
|
||||
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.')
|
||||
}
|
||||
|
||||
// 1. Fetch Yacht details
|
||||
const yachtRecord = await db.yachts.get(logbookId);
|
||||
if (yachtRecord) {
|
||||
try {
|
||||
const yacht = await decryptJson(yachtRecord.encryptedData, yachtRecord.iv, yachtRecord.tag, masterKey);
|
||||
yachtName = yacht.name || '';
|
||||
homePort = yacht.port || '';
|
||||
registration = yacht.registrationNumber || yacht.registration || '';
|
||||
callsign = yacht.callSign || '';
|
||||
atis = yacht.atis || '';
|
||||
mmsi = yacht.mmsi || '';
|
||||
} catch (e) {
|
||||
console.error('Failed to decrypt yacht details for PDF:', e);
|
||||
}
|
||||
const { resolveVesselForLogbook } = await import('./resolveVessel.js')
|
||||
const yacht = await resolveVesselForLogbook(logbookId)
|
||||
if (yacht) {
|
||||
yachtName = yacht.name || ''
|
||||
owner = yacht.owner || ''
|
||||
homePort = yacht.homePort || ''
|
||||
registration = yacht.registrationNumber || ''
|
||||
callsign = yacht.callSign || ''
|
||||
atis = yacht.atis || ''
|
||||
mmsi = yacht.mmsi || ''
|
||||
}
|
||||
|
||||
// 2. Fetch active Entry
|
||||
@@ -79,24 +76,56 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
|
||||
doc.setFontSize(8.5);
|
||||
doc.setFont('Helvetica', 'normal');
|
||||
doc.text(`Yachtname: ${yachtName || '—'}`, 10, 21);
|
||||
doc.text(`Heimathafen: ${homePort || '—'}`, 60, 21);
|
||||
doc.text(`Kennzeichen: ${registration || '—'}`, 110, 21);
|
||||
doc.text(`Rufzeichen: ${callsign || '—'}`, 160, 21);
|
||||
doc.text(`ATIS: ${atis || '—'}`, 210, 21);
|
||||
doc.text(`MMSI: ${mmsi || '—'}`, 250, 21);
|
||||
doc.text(`Eigner: ${owner || '—'}`, 55, 21);
|
||||
doc.text(`Heimathafen: ${homePort || '—'}`, 100, 21);
|
||||
doc.text(`Kennzeichen: ${registration || '—'}`, 145, 21);
|
||||
doc.text(`Rufzeichen: ${callsign || '—'}`, 190, 21);
|
||||
doc.text(`ATIS: ${atis || '—'}`, 230, 21);
|
||||
doc.text(`MMSI: ${mmsi || '—'}`, 260, 21);
|
||||
|
||||
doc.text(`Datum: ${entry.date || '—'}`, 10, 23);
|
||||
doc.text(`Reisetag: ${entry.dayOfTravel || '—'}`, 60, 23);
|
||||
doc.text(`Reise von (Departure): ${entry.departure || '—'}`, 110, 23);
|
||||
doc.text(`nach (Destination): ${entry.destination || '—'}`, 200, 23);
|
||||
doc.text(`Datum: ${entry.date || '—'}`, 10, 24);
|
||||
doc.text(`Reisetag: ${entry.dayOfTravel || '—'}`, 60, 24);
|
||||
doc.text(`Reise von (Departure): ${entry.departure || '—'}`, 110, 24);
|
||||
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) {
|
||||
doc.setFont('Helvetica', 'normal');
|
||||
doc.text(
|
||||
`GPS-Track: ${entry.trackDistanceNm} sm · max. ${entry.trackSpeedMaxKn ?? '—'} kn · Ø ${entry.trackSpeedAvgKn ?? '—'} kn`,
|
||||
10,
|
||||
27
|
||||
);
|
||||
if (crewText) {
|
||||
doc.text(crewText, 140, 27);
|
||||
}
|
||||
} else if (crewText) {
|
||||
doc.text(crewText, 10, 27);
|
||||
}
|
||||
|
||||
// Divider line
|
||||
@@ -180,8 +209,28 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
|
||||
doc.text(gps, writeX + 1, y + 4.2);
|
||||
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
|
||||
const remarks = ev.remarks || '';
|
||||
let remarks = ev.remarks || '';
|
||||
if (initial) {
|
||||
remarks = `[${initial}] ${remarks}`;
|
||||
}
|
||||
const maxChars = 65;
|
||||
const clippedRemarks = remarks.length > maxChars ? remarks.substring(0, maxChars) + '...' : remarks;
|
||||
doc.text(clippedRemarks, writeX + 1, y + 4.2);
|
||||
|
||||
@@ -16,7 +16,7 @@ export async function syncPersonPool(): Promise<void> {
|
||||
}
|
||||
|
||||
async function pushPersonPool(): Promise<void> {
|
||||
const pending = await db.userSyncQueue.toArray()
|
||||
const pending = (await db.userSyncQueue.toArray()).filter((item) => item.type === 'person')
|
||||
if (pending.length === 0) return
|
||||
|
||||
try {
|
||||
|
||||
@@ -55,9 +55,6 @@ export async function saveEntryPhoto(options: {
|
||||
})
|
||||
|
||||
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: analyticsContext })
|
||||
if (analyticsContext === 'live_log') {
|
||||
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_PHOTO_UPLOADED)
|
||||
}
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
return photoId
|
||||
}
|
||||
|
||||
@@ -27,17 +27,62 @@ export function getNotificationPermission(): NotificationPermission | 'unsupport
|
||||
return Notification.permission
|
||||
}
|
||||
|
||||
let cachedVapidKey: string | null = null
|
||||
let cachedRegistration: ServiceWorkerRegistration | null = null
|
||||
|
||||
async function getRegistrationCompat(timeoutMs = 8000): Promise<ServiceWorkerRegistration> {
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
throw new Error('Service Worker is not supported by your browser')
|
||||
}
|
||||
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.getRegistration()
|
||||
if (reg) return reg
|
||||
} catch (e) {
|
||||
console.warn('Failed to get service worker registration directly:', e)
|
||||
}
|
||||
|
||||
// Fallback to waiting for ready state with a timeout
|
||||
const readyPromise = navigator.serviceWorker.ready
|
||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Timeout waiting for Service Worker ready state')), timeoutMs)
|
||||
)
|
||||
|
||||
return Promise.race([readyPromise, timeoutPromise])
|
||||
}
|
||||
|
||||
export async function preloadPushService(): Promise<void> {
|
||||
if (!isPushSupported()) return
|
||||
try {
|
||||
if (!cachedVapidKey) {
|
||||
await fetchVapidPublicKey()
|
||||
}
|
||||
if (!cachedRegistration) {
|
||||
cachedRegistration = await getRegistrationCompat()
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to preload push service:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchVapidPublicKey(): Promise<string | null> {
|
||||
if (cachedVapidKey) return cachedVapidKey
|
||||
|
||||
const envKey = import.meta.env.VITE_VAPID_PUBLIC_KEY
|
||||
if (typeof envKey === 'string' && envKey.trim()) {
|
||||
return envKey.trim()
|
||||
cachedVapidKey = envKey.trim()
|
||||
return cachedVapidKey
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/vapid-public-key`)
|
||||
if (!res.ok) return null
|
||||
const data = await res.json()
|
||||
return typeof data.publicKey === 'string' ? data.publicKey : null
|
||||
if (typeof data.publicKey === 'string') {
|
||||
cachedVapidKey = data.publicKey.trim()
|
||||
return cachedVapidKey
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@@ -72,11 +117,61 @@ export async function savePushPrefs(collaboratorChangesEnabled: boolean): Promis
|
||||
})
|
||||
}
|
||||
|
||||
async function requestNotificationPermission(): Promise<NotificationPermission> {
|
||||
if (typeof Notification === 'undefined') return 'denied'
|
||||
|
||||
// Try promise-based signature first
|
||||
try {
|
||||
const result = Notification.requestPermission()
|
||||
if (result !== undefined) {
|
||||
return await result
|
||||
}
|
||||
} catch {
|
||||
// Ignore and fall back to callback
|
||||
}
|
||||
|
||||
// Callback-based fallback
|
||||
return new Promise<NotificationPermission>((resolve) => {
|
||||
Notification.requestPermission((permission) => {
|
||||
resolve(permission)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function saveSubscriptionToServer(subscription: PushSubscription): Promise<void> {
|
||||
if (!localStorage.getItem('active_userid')) throw new Error('Not authenticated')
|
||||
|
||||
const endpoint = subscription.endpoint
|
||||
const json = subscription.toJSON()
|
||||
if (!json.endpoint || !json.keys?.p256dh || !json.keys?.auth) {
|
||||
let p256dh = json.keys?.p256dh
|
||||
let auth = json.keys?.auth
|
||||
|
||||
// Fallback for browsers (like Safari) that might not serialize keys in toJSON()
|
||||
if (!p256dh && typeof subscription.getKey === 'function') {
|
||||
try {
|
||||
const rawKey = subscription.getKey('p256dh')
|
||||
if (rawKey) {
|
||||
p256dh = btoa(String.fromCharCode(...new Uint8Array(rawKey)))
|
||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to extract p256dh key manually:', e)
|
||||
}
|
||||
}
|
||||
|
||||
if (!auth && typeof subscription.getKey === 'function') {
|
||||
try {
|
||||
const rawAuth = subscription.getKey('auth')
|
||||
if (rawAuth) {
|
||||
auth = btoa(String.fromCharCode(...new Uint8Array(rawAuth)))
|
||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to extract auth key manually:', e)
|
||||
}
|
||||
}
|
||||
|
||||
if (!endpoint || !p256dh || !auth) {
|
||||
throw new Error('Invalid push subscription')
|
||||
}
|
||||
|
||||
@@ -85,8 +180,8 @@ async function saveSubscriptionToServer(subscription: PushSubscription): Promise
|
||||
await apiJson(`${API_BASE}/subscription`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
endpoint: json.endpoint,
|
||||
keys: json.keys,
|
||||
endpoint,
|
||||
keys: { p256dh, auth },
|
||||
locale,
|
||||
userAgent: navigator.userAgent
|
||||
})
|
||||
@@ -98,35 +193,48 @@ export async function subscribeToPush(): Promise<void> {
|
||||
throw new Error('Push notifications are not supported on this device')
|
||||
}
|
||||
|
||||
const permission = await Notification.requestPermission()
|
||||
if (permission !== 'granted') {
|
||||
throw new Error('Notification permission denied')
|
||||
// Pre-resolve registration using getRegistrationCompat to prevent ready state hangs
|
||||
let registration = cachedRegistration
|
||||
if (!registration) {
|
||||
registration = await getRegistrationCompat()
|
||||
cachedRegistration = registration
|
||||
}
|
||||
|
||||
const publicKey = await fetchVapidPublicKey()
|
||||
const publicKey = cachedVapidKey || await fetchVapidPublicKey()
|
||||
if (!publicKey) {
|
||||
throw new Error('Push notifications are not configured on this server')
|
||||
}
|
||||
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
let subscription = await registration.pushManager.getSubscription()
|
||||
|
||||
if (!subscription) {
|
||||
const keyBytes = urlBase64ToUint8Array(publicKey)
|
||||
const applicationServerKey = new Uint8Array(keyBytes)
|
||||
subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey
|
||||
})
|
||||
const permission = await requestNotificationPermission()
|
||||
if (permission !== 'granted') {
|
||||
throw new Error('Notification permission denied')
|
||||
}
|
||||
|
||||
const keyBytes = urlBase64ToUint8Array(publicKey)
|
||||
const applicationServerKey = new Uint8Array(keyBytes)
|
||||
|
||||
// Always call subscribe with timeout to prevent silent hangs on push network errors
|
||||
const subscribePromise = registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey
|
||||
})
|
||||
const subscribeTimeout = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Timeout establishing subscription with push service (FCM/APNs)')), 12000)
|
||||
)
|
||||
const subscription = await Promise.race([subscribePromise, subscribeTimeout])
|
||||
|
||||
await saveSubscriptionToServer(subscription)
|
||||
}
|
||||
|
||||
export async function unsubscribeFromPush(): Promise<void> {
|
||||
if (!isPushSupported()) return
|
||||
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
let registration = cachedRegistration
|
||||
if (!registration) {
|
||||
registration = await getRegistrationCompat()
|
||||
cachedRegistration = registration
|
||||
}
|
||||
|
||||
const subscription = await registration.pushManager.getSubscription()
|
||||
if (!subscription) return
|
||||
|
||||
@@ -164,3 +272,7 @@ export async function disableCollaboratorChangePush(): Promise<void> {
|
||||
await savePushPrefs(false)
|
||||
await unsubscribeFromPush()
|
||||
}
|
||||
|
||||
if (isPushSupported()) {
|
||||
void preloadPushService()
|
||||
}
|
||||
|
||||
@@ -3,11 +3,13 @@ import { getActiveMasterKey } from './auth.js'
|
||||
import { ensureLogbookKey, getLogbookKey } from './logbookKeys.js'
|
||||
import { decryptJson, encryptJson } from './crypto.js'
|
||||
import { syncLogbook } from './sync.js'
|
||||
import { putEntryRecord } from '../utils/entryListCache.js'
|
||||
import {
|
||||
buildLogEntryPayload,
|
||||
normalizeLogEvent,
|
||||
sortLogEventsByTime,
|
||||
currentLocalTimeHHMM,
|
||||
localDateString,
|
||||
type LogEventPayload
|
||||
} from '../utils/logEntryPayload.js'
|
||||
import {
|
||||
@@ -95,6 +97,14 @@ function buildEncryptedPayload(
|
||||
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({
|
||||
date: String(data.date || ''),
|
||||
dayOfTravel: String(data.dayOfTravel || ''),
|
||||
@@ -119,15 +129,27 @@ function buildEncryptedPayload(
|
||||
motorHoursRaw != null && motorHoursRaw !== ''
|
||||
? parseFloat(String(motorHoursRaw))
|
||||
: undefined,
|
||||
events: options.events
|
||||
events: options.events,
|
||||
entryCrew
|
||||
})
|
||||
|
||||
const clear = options.clearSignatures
|
||||
return {
|
||||
const entryData: Record<string, unknown> = {
|
||||
...payload,
|
||||
signSkipper: clear ? '' : (data.signSkipper ?? ''),
|
||||
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> {
|
||||
@@ -139,18 +161,86 @@ export async function loadEntry(logbookId: string, entryId: string): Promise<Loa
|
||||
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> {
|
||||
const todayStr = new Date().toISOString().substring(0, 10)
|
||||
const todayStr = localDateString()
|
||||
const masterKey = await getMasterKey(logbookId)
|
||||
const local = sortEntriesNewestFirst(await db.entries.where({ logbookId }).toArray())
|
||||
|
||||
let bestId: string | null = null
|
||||
let bestScore = -1
|
||||
let bestUpdatedAt = ''
|
||||
|
||||
for (const entry of local) {
|
||||
const decrypted = await tryDecryptEntryPayload(entry, masterKey)
|
||||
if (decrypted && String(decrypted.date) === todayStr) {
|
||||
return entry.payloadId
|
||||
if (!decrypted || String(decrypted.date) !== todayStr) continue
|
||||
|
||||
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> {
|
||||
@@ -173,7 +263,7 @@ export async function createTodayEntry(logbookId: string): Promise<string> {
|
||||
|
||||
const localId = window.crypto.randomUUID()
|
||||
const nowStr = new Date().toISOString()
|
||||
const todayStr = nowStr.substring(0, 10)
|
||||
const todayStr = localDateString()
|
||||
|
||||
const initialPayload = {
|
||||
date: todayStr,
|
||||
@@ -190,14 +280,17 @@ export async function createTodayEntry(logbookId: string): Promise<string> {
|
||||
|
||||
const encrypted = await encryptJson(initialPayload, masterKey)
|
||||
|
||||
await db.entries.put({
|
||||
payloadId: localId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: nowStr
|
||||
})
|
||||
await putEntryRecord(
|
||||
{
|
||||
payloadId: localId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: nowStr
|
||||
},
|
||||
initialPayload
|
||||
)
|
||||
|
||||
await db.syncQueue.put({
|
||||
action: 'create',
|
||||
@@ -212,20 +305,36 @@ export async function createTodayEntry(logbookId: string): Promise<string> {
|
||||
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> {
|
||||
const id = logbookId.trim()
|
||||
if (!id) throw new Error('Logbook id required')
|
||||
|
||||
await ensureLogbookKey(id)
|
||||
|
||||
const entryCount = await db.entries.where({ logbookId: id }).count()
|
||||
if (entryCount === 0) {
|
||||
return createTodayEntry(id)
|
||||
let inflight = findOrCreateTodayEntryInflight.get(id)
|
||||
if (!inflight) {
|
||||
inflight = findOrCreateTodayEntryOnce(id)
|
||||
findOrCreateTodayEntryInflight.set(id, inflight)
|
||||
void inflight.finally(() => {
|
||||
if (findOrCreateTodayEntryInflight.get(id) === inflight) {
|
||||
findOrCreateTodayEntryInflight.delete(id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const existing = await findTodayEntryId(id)
|
||||
if (existing) return existing
|
||||
return createTodayEntry(id)
|
||||
return inflight
|
||||
}
|
||||
|
||||
export interface AppendQuickEventResult {
|
||||
@@ -305,14 +414,17 @@ async function persistEntry(
|
||||
const encrypted = await encryptJson(entryData, masterKey)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
await db.entries.put({
|
||||
payloadId: entryId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
await putEntryRecord(
|
||||
{
|
||||
payloadId: entryId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
},
|
||||
entryData
|
||||
)
|
||||
|
||||
await db.syncQueue.put({
|
||||
action: 'update',
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
export function formatNm(value: number): string {
|
||||
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)
|
||||
}
|
||||
export { formatHours, formatLiters, formatNm } from '../utils/numberFormat.js'
|
||||
|
||||
+81
-15
@@ -8,6 +8,7 @@ import {
|
||||
type SyncConflict
|
||||
} from './syncConflicts.js'
|
||||
import { syncPersonPool } from './personPoolSync.js'
|
||||
import { forEachInBatches, yieldToMain } from '../utils/yieldToMain.js'
|
||||
|
||||
const API_BASE = '/api/sync'
|
||||
const syncingLogbooks = new Set<string>()
|
||||
@@ -60,10 +61,14 @@ async function entityExistsLocally(item: SyncQueueItem): Promise<boolean> {
|
||||
return !!(await db.entries.get(item.payloadId))
|
||||
case 'photo':
|
||||
return !!(await db.photos.get(item.payloadId))
|
||||
case 'voiceMemo':
|
||||
return !!(await db.voiceMemos.get(item.payloadId))
|
||||
case 'gpsTrack':
|
||||
return !!(await db.gpsTracks.get(item.payloadId))
|
||||
case 'logbookCrew':
|
||||
return !!(await db.logbookCrewSelections.get(item.logbookId))
|
||||
case 'logbookVessel':
|
||||
return !!(await db.logbookVesselSelections.get(item.logbookId))
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -128,12 +133,7 @@ async function coalesceSyncQueue(logbookId: string): Promise<SyncQueueItem[]> {
|
||||
}
|
||||
|
||||
function scheduleResync(logbookId: string) {
|
||||
if (pendingResync.has(logbookId)) return
|
||||
pendingResync.add(logbookId)
|
||||
queueMicrotask(() => {
|
||||
pendingResync.delete(logbookId)
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Deferred sync failed:', err))
|
||||
})
|
||||
}
|
||||
|
||||
type LogbookPushAccess = 'OWNER' | 'WRITE' | 'READ' | 'UNKNOWN'
|
||||
@@ -228,9 +228,11 @@ type PulledServerPayload = {
|
||||
yacht?: { updatedAt: string } | null
|
||||
deviation?: { updatedAt: string } | null
|
||||
logbookCrewSelection?: { updatedAt: string } | null
|
||||
logbookVesselSelection?: { updatedAt: string } | null
|
||||
crews?: Array<{ payloadId: string; updatedAt: string }>
|
||||
entries?: Array<{ payloadId: string; updatedAt: string }>
|
||||
photos?: Array<{ payloadId: string; updatedAt: string }>
|
||||
voiceMemos?: Array<{ payloadId: string; updatedAt: string }>
|
||||
gpsTracks?: Array<{ entryId: string; updatedAt: string }>
|
||||
}
|
||||
|
||||
@@ -248,9 +250,13 @@ async function pruneAcknowledgedQueueItems(
|
||||
if (server.logbookCrewSelection) {
|
||||
serverTimes.set('logbookCrew:' + logbookId, server.logbookCrewSelection.updatedAt)
|
||||
}
|
||||
if (server.logbookVesselSelection) {
|
||||
serverTimes.set('logbookVessel:' + logbookId, server.logbookVesselSelection.updatedAt)
|
||||
}
|
||||
for (const c of server.crews ?? []) serverTimes.set('crew:' + c.payloadId, c.updatedAt)
|
||||
for (const e of server.entries ?? []) serverTimes.set('entry:' + e.payloadId, e.updatedAt)
|
||||
for (const p of server.photos ?? []) serverTimes.set('photo:' + p.payloadId, p.updatedAt)
|
||||
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)
|
||||
|
||||
const localLogbook = await db.logbooks.get(logbookId)
|
||||
@@ -269,7 +275,9 @@ async function pruneAcknowledgedQueueItems(
|
||||
? 'yacht:' + logbookId
|
||||
: item.type === 'logbookCrew'
|
||||
? 'logbookCrew:' + logbookId
|
||||
: `${item.type}:${item.payloadId}`
|
||||
: item.type === 'logbookVessel'
|
||||
? 'logbookVessel:' + logbookId
|
||||
: `${item.type}:${item.payloadId}`
|
||||
const serverUpdatedAt = serverTimes.get(key)
|
||||
if (serverUpdatedAt && !isNewer(item.updatedAt, serverUpdatedAt)) {
|
||||
if (item.id !== undefined) staleIds.push(item.id)
|
||||
@@ -295,15 +303,21 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
||||
return false
|
||||
}
|
||||
|
||||
const { yacht, deviation, crews, logbookCrewSelection, entries, photos, gpsTracks } =
|
||||
const { yacht, deviation, crews, logbookCrewSelection, logbookVesselSelection, entries, photos, voiceMemos, 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
|
||||
}
|
||||
|
||||
@@ -349,10 +363,24 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
// 2c. Sync Logbook Vessel Selection
|
||||
if (logbookVesselSelection) {
|
||||
const local = await db.logbookVesselSelections.get(logbookId)
|
||||
if (!local || isNewer(logbookVesselSelection.updatedAt, local.updatedAt)) {
|
||||
await db.logbookVesselSelections.put({
|
||||
logbookId,
|
||||
encryptedData: logbookVesselSelection.encryptedData,
|
||||
iv: logbookVesselSelection.iv,
|
||||
tag: logbookVesselSelection.tag,
|
||||
updatedAt: logbookVesselSelection.updatedAt
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Sync Crew List Payloads (legacy)
|
||||
const serverCrewMap = new Map<string, any>()
|
||||
if (crews && Array.isArray(crews)) {
|
||||
for (const c of crews) {
|
||||
await forEachInBatches(crews, 20, async (c) => {
|
||||
serverCrewMap.set(c.payloadId, c)
|
||||
const local = await db.crews.get(c.payloadId)
|
||||
if (!local || isNewer(c.updatedAt, local.updatedAt)) {
|
||||
@@ -365,7 +393,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
||||
updatedAt: c.updatedAt
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Deletions for Crew: If present locally but not on server, and not pending creation locally
|
||||
@@ -385,7 +413,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
||||
// 4. Sync Journal Entry Payloads
|
||||
const serverEntryMap = new Map<string, any>()
|
||||
if (entries && Array.isArray(entries)) {
|
||||
for (const e of entries) {
|
||||
await forEachInBatches(entries, 15, async (e) => {
|
||||
serverEntryMap.set(e.payloadId, e)
|
||||
const local = await db.entries.get(e.payloadId)
|
||||
if (!local || isNewer(e.updatedAt, local.updatedAt)) {
|
||||
@@ -398,7 +426,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
||||
updatedAt: e.updatedAt
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Deletions for Entries
|
||||
@@ -417,7 +445,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
||||
// 5. Sync Photos
|
||||
const serverPhotoMap = new Map<string, any>()
|
||||
if (photos && Array.isArray(photos)) {
|
||||
for (const p of photos) {
|
||||
await forEachInBatches(photos, 20, async (p) => {
|
||||
serverPhotoMap.set(p.payloadId, p)
|
||||
const local = await db.photos.get(p.payloadId)
|
||||
if (!local || isNewer(p.updatedAt, local.updatedAt)) {
|
||||
@@ -432,7 +460,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
||||
updatedAt: p.updatedAt
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Deletions for Photos
|
||||
@@ -448,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
|
||||
const serverGpsTrackMap = new Map<string, any>()
|
||||
if (gpsTracks && Array.isArray(gpsTracks)) {
|
||||
for (const gt of gpsTracks) {
|
||||
await forEachInBatches(gpsTracks, 10, async (gt) => {
|
||||
serverGpsTrackMap.set(gt.entryId, gt)
|
||||
const local = await db.gpsTracks.get(gt.entryId)
|
||||
if (!local || isNewer(gt.updatedAt, local.updatedAt)) {
|
||||
@@ -464,7 +524,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
||||
updatedAt: gt.updatedAt
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Deletions for GPS Tracks
|
||||
@@ -512,6 +572,12 @@ export async function syncLogbook(logbookId: string): Promise<boolean> {
|
||||
} finally {
|
||||
syncingLogbooks.delete(logbookId)
|
||||
recomputeSyncingState()
|
||||
if (pendingResync.has(logbookId)) {
|
||||
pendingResync.delete(logbookId)
|
||||
setTimeout(() => {
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Deferred sync failed:', err))
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
getThemePreference,
|
||||
setColorSchemePreference,
|
||||
setOwmApiKey,
|
||||
setThemePreference
|
||||
setThemePreference,
|
||||
getAiAuthorized,
|
||||
setAiAuthorized
|
||||
} from './userPreferences.js'
|
||||
|
||||
const USER_ID = 'test-user-123'
|
||||
@@ -58,4 +60,13 @@ describe('userPreferences', () => {
|
||||
expect(getThemePreference(USER_ID)).toBe('ocean')
|
||||
expect(getColorSchemePreference(USER_ID)).toBe('light')
|
||||
})
|
||||
|
||||
it('stores AI authorization preference per user', () => {
|
||||
localStorage.setItem('active_userid', USER_ID)
|
||||
expect(getAiAuthorized()).toBe(false)
|
||||
setAiAuthorized(USER_ID, true)
|
||||
expect(getAiAuthorized()).toBe(true)
|
||||
expect(getAiAuthorized(USER_ID)).toBe(true)
|
||||
expect(getAiAuthorized('other-user')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -89,3 +89,20 @@ export function setOwmApiKey(userId: string, value: string): void {
|
||||
localStorage.removeItem(owmKey(userId))
|
||||
}
|
||||
}
|
||||
|
||||
function aiAuthorizedKey(userId: string): string {
|
||||
return `user_pref_ai_authorized_${userId}`
|
||||
}
|
||||
|
||||
export function getAiAuthorized(userId?: string | null): boolean {
|
||||
const id = resolveUserId(userId)
|
||||
if (id) {
|
||||
return localStorage.getItem(aiAuthorizedKey(id)) === 'true'
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function setAiAuthorized(userId: string, value: boolean): void {
|
||||
localStorage.setItem(aiAuthorizedKey(userId), String(value))
|
||||
}
|
||||
|
||||
|
||||
@@ -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,155 @@
|
||||
import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { getLogbookKey } from './logbookKeys.js'
|
||||
import { encryptJson, decryptJson } 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
|
||||
transcribed?: boolean
|
||||
analyticsContext?: string
|
||||
}): Promise<string> {
|
||||
const {
|
||||
logbookId,
|
||||
entryId,
|
||||
audioDataUrl,
|
||||
mimeType,
|
||||
durationSec,
|
||||
caption = '',
|
||||
transcribed = true,
|
||||
analyticsContext = 'logbook'
|
||||
} = options
|
||||
const masterKey = await getEncryptionKey(logbookId)
|
||||
const voiceId = window.crypto.randomUUID()
|
||||
const voicePayload = {
|
||||
audio: audioDataUrl,
|
||||
mimeType,
|
||||
durationSec,
|
||||
caption: caption.trim(),
|
||||
transcribed: !!transcribed
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/** Updates an existing voice memo payload with a new transcript and sets transcribed: true. */
|
||||
export async function updateVoiceMemoTranscript(
|
||||
logbookId: string,
|
||||
voiceId: string,
|
||||
transcript: string
|
||||
): Promise<void> {
|
||||
const masterKey = await getEncryptionKey(logbookId)
|
||||
const record = await db.voiceMemos.get(voiceId)
|
||||
if (!record) throw new Error('Voice memo not found')
|
||||
|
||||
const decrypted = await decryptJson(record.encryptedData, record.iv, record.tag, masterKey)
|
||||
if (!decrypted) throw new Error('Failed to decrypt voice memo')
|
||||
|
||||
const manualCaption = decrypted.caption ? String(decrypted.caption).trim() : ''
|
||||
const finalCaption = manualCaption
|
||||
? `${manualCaption}\n(Transkript: ${transcript.trim()})`
|
||||
: transcript.trim()
|
||||
|
||||
const updatedPayload = {
|
||||
...decrypted,
|
||||
caption: finalCaption,
|
||||
transcribed: true
|
||||
}
|
||||
|
||||
const encrypted = await encryptJson(updatedPayload, masterKey)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
await db.voiceMemos.put({
|
||||
...record,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
await db.syncQueue.put({
|
||||
action: 'update',
|
||||
type: 'voiceMemo',
|
||||
payloadId: voiceId,
|
||||
logbookId,
|
||||
data: JSON.stringify({
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
entryId: record.entryId
|
||||
}),
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
}
|
||||
@@ -44,6 +44,17 @@ describe('fetchOpenWeatherCurrent', () => {
|
||||
})
|
||||
})
|
||||
|
||||
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,
|
||||
@@ -58,4 +69,51 @@ describe('fetchOpenWeatherCurrent', () => {
|
||||
|
||||
expect(trackPlausibleEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws UNAUTHORIZED when status is 401', async () => {
|
||||
apiFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({ error: 'Unauthorized' })
|
||||
})
|
||||
|
||||
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 any).code).toBe('UNAUTHORIZED')
|
||||
})
|
||||
|
||||
it('throws NOT_FOUND when status is 404', async () => {
|
||||
apiFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({ error: 'Not Found' })
|
||||
})
|
||||
|
||||
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 any).code).toBe('NOT_FOUND')
|
||||
})
|
||||
|
||||
it('throws BAD_REQUEST when status is 400', async () => {
|
||||
apiFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
json: async () => ({ error: 'Bad Request' })
|
||||
})
|
||||
|
||||
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 any).code).toBe('BAD_REQUEST')
|
||||
})
|
||||
|
||||
it('throws BAD_REQUEST when coordinates or query are missing', async () => {
|
||||
const { fetchOpenWeatherCurrent, WeatherApiError } = await import('./weather.js')
|
||||
const err = await fetchOpenWeatherCurrent({}).catch((e) => e)
|
||||
expect(err).toBeInstanceOf(WeatherApiError)
|
||||
expect((err as any).code).toBe('BAD_REQUEST')
|
||||
expect(apiFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user