Compare commits
136 Commits
v0.1.0.99
...
944f4518e9
| Author | SHA1 | Date | |
|---|---|---|---|
| 944f4518e9 | |||
| 0c765f712c | |||
| 676547686b | |||
| 66606c5eca | |||
| a30fac029d | |||
| 796e61f4ea | |||
| 594c65d1a5 | |||
| fafefff29b | |||
| 4fd7f3c6cf | |||
| 262c48a01a | |||
| 9ad3c2cf38 | |||
| 6848390ffa | |||
| 65d2215a35 | |||
| f321e5bbd1 | |||
| d2961b050a | |||
| 6943fd2dc4 | |||
| f332eccf22 | |||
| 9d2a19dbf8 | |||
| e3cd89be5d | |||
| a86da72b04 | |||
| 7d6f381f55 | |||
| 878be33b7c | |||
| 318f5e65da | |||
| 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 |
+25
-2
@@ -1,5 +1,11 @@
|
|||||||
OpenWeatherMapAPIKey=<owm_api_key>
|
OpenWeatherMapAPIKey=<owm_api_key>
|
||||||
|
|
||||||
|
# OpenRouter API (AI travel day summaries — server-side proxy)
|
||||||
|
OpenRouterAPIKey=
|
||||||
|
# Optional model override (default: anthropic/claude-3.5-haiku)
|
||||||
|
# Valid examples: anthropic/claude-3.5-haiku, anthropic/claude-3-haiku, anthropic/claude-haiku-4.5
|
||||||
|
# OpenRouterModel=anthropic/claude-3.5-haiku
|
||||||
|
|
||||||
# DeepL API (for scripts/translate-locales.mjs and scripts/translate-flyer.mjs)
|
# DeepL API (for scripts/translate-locales.mjs and scripts/translate-flyer.mjs)
|
||||||
# Free plan keys use api-free.deepl.com automatically (suffix :fx)
|
# Free plan keys use api-free.deepl.com automatically (suffix :fx)
|
||||||
DeepLAPIKey=
|
DeepLAPIKey=
|
||||||
@@ -9,12 +15,17 @@ DeepLAPIKey=
|
|||||||
# Production (kapteins-daagbok.eu):
|
# Production (kapteins-daagbok.eu):
|
||||||
# RP_ID=kapteins-daagbok.eu
|
# RP_ID=kapteins-daagbok.eu
|
||||||
# ORIGIN=https://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
|
RP_ID=localhost
|
||||||
# Must match the frontend URL exactly (Vite dev: http://localhost:5173; Docker: http://localhost)
|
# Must match the frontend URL exactly (Vite dev: http://localhost:5173; Docker: http://localhost)
|
||||||
ORIGIN=http://localhost:5173
|
ORIGIN=http://localhost:5173
|
||||||
|
|
||||||
# Behind Nginx Proxy Manager — see docs/deployment/npm-security.md
|
# Behind reverse proxy — see docs/deployment/npm-security.md
|
||||||
# TRUST_PROXY=172.16.10.10
|
# Docker Compose (NPM → frontend nginx → backend): TRUST_PROXY=1
|
||||||
# TRUST_PROXY=1
|
# TRUST_PROXY=1
|
||||||
|
|
||||||
# Docker Compose database (required for production deploy)
|
# Docker Compose database (required for production deploy)
|
||||||
@@ -23,6 +34,8 @@ ORIGIN=http://localhost:5173
|
|||||||
# POSTGRES_USER=postgres
|
# POSTGRES_USER=postgres
|
||||||
# POSTGRES_PASSWORD=
|
# POSTGRES_PASSWORD=
|
||||||
# POSTGRES_DB=daagbox
|
# POSTGRES_DB=daagbox
|
||||||
|
# Optional: lock Docker Compose to a specific configuration file (e.g. staging or production) on the server:
|
||||||
|
# COMPOSE_FILE=docker-compose.staging.yml
|
||||||
# Optional: comma-separated CORS origins (defaults to ORIGIN; 127.0.0.1 may be allowed for CORS but not for login)
|
# Optional: comma-separated CORS origins (defaults to ORIGIN; 127.0.0.1 may be allowed for CORS but not for login)
|
||||||
# CORS_ORIGINS=http://localhost:5173
|
# CORS_ORIGINS=http://localhost:5173
|
||||||
|
|
||||||
@@ -30,6 +43,10 @@ ORIGIN=http://localhost:5173
|
|||||||
# Generate: openssl rand -base64 48
|
# Generate: openssl rand -base64 48
|
||||||
SESSION_SECRET=
|
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
|
# 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
|
# Public key may also be set on the client as VITE_VAPID_PUBLIC_KEY
|
||||||
VAPID_PUBLIC_KEY=
|
VAPID_PUBLIC_KEY=
|
||||||
@@ -41,3 +58,9 @@ VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu
|
|||||||
NTFY_SERVER=https://ntfy.sh
|
NTFY_SERVER=https://ntfy.sh
|
||||||
NTFY_TOPIC=kapteins-daagbok-feedback
|
NTFY_TOPIC=kapteins-daagbok-feedback
|
||||||
NTFY_TOKEN=tk_example_ntfy_access_token
|
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
|
```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.
|
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).
|
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
|
## Dokumentation
|
||||||
|
|
||||||
| Dokument | Inhalt |
|
| 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/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/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/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/plausible-events.md](docs/plausible-events.md) | Custom Events für Plausible Analytics |
|
||||||
| [docs/push-notifications-plan.md](docs/push-notifications-plan.md) | Web Push: Architektur, API, Testplan |
|
| [docs/push-notifications-plan.md](docs/push-notifications-plan.md) | Web Push: Architektur, API, Testplan |
|
||||||
| [docs/plan-compass-course-dial.md](docs/plan-compass-course-dial.md) | Kompass-Dial: UX- und Implementierungsplan |
|
| [docs/plan-compass-course-dial.md](docs/plan-compass-course-dial.md) | Kompass-Dial: UX- und Implementierungsplan |
|
||||||
|
|||||||
+8
-5
@@ -18,15 +18,18 @@ RUN npm run build
|
|||||||
FROM nginx:1.25-alpine
|
FROM nginx:1.25-alpine
|
||||||
WORKDIR /usr/share/nginx/html
|
WORKDIR /usr/share/nginx/html
|
||||||
|
|
||||||
# Copy custom Nginx configuration
|
RUN apk add --no-cache gettext
|
||||||
COPY client/nginx.conf /etc/nginx/conf.d/default.conf
|
|
||||||
|
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 built assets from builder
|
||||||
COPY --from=builder /app/dist .
|
COPY --from=builder /app/dist .
|
||||||
|
|
||||||
# Expose HTTP port
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
# Health check to verify Nginx is actively running
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=3s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=3s --retries=3 \
|
||||||
CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1
|
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:80/ || exit 1
|
||||||
|
|
||||||
|
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;'
|
||||||
+1
-1
@@ -22,6 +22,7 @@
|
|||||||
<meta name="apple-mobile-web-app-title" content="Daagbok" />
|
<meta name="apple-mobile-web-app-title" content="Daagbok" />
|
||||||
<meta name="theme-color" content="#0b0c10" />
|
<meta name="theme-color" content="#0b0c10" />
|
||||||
<script src="/appearance-bootstrap.js"></script>
|
<script src="/appearance-bootstrap.js"></script>
|
||||||
|
<script src="/plausible-bootstrap.js"></script>
|
||||||
<script src="/bootstrap-watchdog.js"></script>
|
<script src="/bootstrap-watchdog.js"></script>
|
||||||
<link rel="apple-touch-icon" href="/logo.png" />
|
<link rel="apple-touch-icon" href="/logo.png" />
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
@@ -38,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: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" content="https://kapteins-daagbok.eu/logo.png" />
|
||||||
<meta name="twitter:image:alt" content="Kapteins Daagbok Logo" />
|
<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>
|
<title>Kapteins Daagbok – Kostenloses digitales Yacht-Logbuch (werbefrei)</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+2
-48
@@ -1,48 +1,2 @@
|
|||||||
server {
|
# Generated at container start from PLAUSIBLE_* — see client/nginx.conf.template and docker-entrypoint.sh
|
||||||
listen 80;
|
# Local Docker Compose uses the template via client/Dockerfile entrypoint.
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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",
|
"bip39": "^3.1.0",
|
||||||
"dexie": "^4.4.2",
|
"dexie": "^4.4.2",
|
||||||
"dexie-react-hooks": "^4.4.0",
|
"dexie-react-hooks": "^4.4.0",
|
||||||
|
"fflate": "^0.8.3",
|
||||||
"i18next": "^26.3.0",
|
"i18next": "^26.3.0",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
|
|||||||
+4
-2
@@ -13,6 +13,7 @@
|
|||||||
"generate:flyer:png": "node ../scripts/generate-beta-flyer.mjs --png",
|
"generate:flyer:png": "node ../scripts/generate-beta-flyer.mjs --png",
|
||||||
"generate:flyer:all": "node ../scripts/generate-beta-flyer.mjs --all",
|
"generate:flyer:all": "node ../scripts/generate-beta-flyer.mjs --all",
|
||||||
"generate:flyer:setup": "playwright install chromium",
|
"generate:flyer:setup": "playwright install chromium",
|
||||||
|
"generate:sharepic": "node ../scripts/generate-sharepic.mjs",
|
||||||
"translate:locales": "node ../scripts/translate-locales.mjs",
|
"translate:locales": "node ../scripts/translate-locales.mjs",
|
||||||
"translate:flyer": "node ../scripts/translate-flyer.mjs",
|
"translate:flyer": "node ../scripts/translate-flyer.mjs",
|
||||||
"validate:i18n": "node ../scripts/validate-i18n-keys.mjs"
|
"validate:i18n": "node ../scripts/validate-i18n-keys.mjs"
|
||||||
@@ -22,15 +23,16 @@
|
|||||||
"bip39": "^3.1.0",
|
"bip39": "^3.1.0",
|
||||||
"dexie": "^4.4.2",
|
"dexie": "^4.4.2",
|
||||||
"dexie-react-hooks": "^4.4.0",
|
"dexie-react-hooks": "^4.4.0",
|
||||||
|
"fflate": "^0.8.3",
|
||||||
"i18next": "^26.3.0",
|
"i18next": "^26.3.0",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^1.16.0",
|
"lucide-react": "^1.16.0",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-i18next": "^17.0.8",
|
"react-i18next": "^17.0.8"
|
||||||
"qrcode": "^1.5.4"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
|||||||
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 */
|
||||||
|
})
|
||||||
|
})()
|
||||||
+839
-64
File diff suppressed because it is too large
Load Diff
+82
-6
@@ -36,6 +36,7 @@ import { syncAppearancePrefs } from './services/appearancePrefs.js'
|
|||||||
import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks, subscribeToSyncState } from './services/sync.js'
|
import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks, subscribeToSyncState } from './services/sync.js'
|
||||||
import ReadOnlyViewer from './components/ReadOnlyViewer.tsx'
|
import ReadOnlyViewer from './components/ReadOnlyViewer.tsx'
|
||||||
import DemoViewer from './components/DemoViewer.tsx'
|
import DemoViewer from './components/DemoViewer.tsx'
|
||||||
|
import AdminDashboard from './admin/AdminDashboard.tsx'
|
||||||
import PwaInstallPrompt from './components/PwaInstallPrompt.tsx'
|
import PwaInstallPrompt from './components/PwaInstallPrompt.tsx'
|
||||||
import PwaUpdatePrompt from './components/PwaUpdatePrompt.tsx'
|
import PwaUpdatePrompt from './components/PwaUpdatePrompt.tsx'
|
||||||
import AppFooter from './components/AppFooter.tsx'
|
import AppFooter from './components/AppFooter.tsx'
|
||||||
@@ -49,6 +50,8 @@ import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, La
|
|||||||
import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
|
import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
|
||||||
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
|
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
|
||||||
import ProfileHeaderButton from './components/ProfileHeaderButton.tsx'
|
import ProfileHeaderButton from './components/ProfileHeaderButton.tsx'
|
||||||
|
import AdminHeaderButton from './components/AdminHeaderButton.tsx'
|
||||||
|
import { checkAdminAccess } from './services/adminApi.js'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { cycleAppLanguage } from './utils/i18nLanguages.js'
|
import { cycleAppLanguage } from './utils/i18nLanguages.js'
|
||||||
import {
|
import {
|
||||||
@@ -92,6 +95,10 @@ function App() {
|
|||||||
|
|
||||||
// Public demo mode (no account required)
|
// Public demo mode (no account required)
|
||||||
const [isDemoMode, setIsDemoMode] = useState(() => window.location.pathname === '/demo')
|
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(
|
const syncQueueCount = useLiveQuery(
|
||||||
() => activeLogbookId ? db.syncQueue.where({ logbookId: activeLogbookId }).count() : db.syncQueue.count(),
|
() => activeLogbookId ? db.syncQueue.where({ logbookId: activeLogbookId }).count() : db.syncQueue.count(),
|
||||||
@@ -103,7 +110,7 @@ function App() {
|
|||||||
[activeLogbookId]
|
[activeLogbookId]
|
||||||
)
|
)
|
||||||
|
|
||||||
const [activeAccessRole, setActiveAccessRole] = useState<LogbookAccessRole | null>('OWNER')
|
const [activeAccessRole, setActiveAccessRole] = useState<LogbookAccessRole | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeLogbookId) {
|
if (!activeLogbookId) {
|
||||||
@@ -160,14 +167,23 @@ function App() {
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const refreshAdminAccess = useCallback(async () => {
|
||||||
|
const isAdmin = await checkAdminAccess()
|
||||||
|
setIsAdminUser(isAdmin)
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated) return
|
if (!isAuthenticated) {
|
||||||
|
setIsAdminUser(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
const userId = localStorage.getItem('active_userid')
|
const userId = localStorage.getItem('active_userid')
|
||||||
if (!userId) return
|
if (!userId) return
|
||||||
void syncAppearancePrefs(userId)
|
void syncAppearancePrefs(userId)
|
||||||
void migrateLegacyCrewToPoolIfNeeded().then(() => syncPersonPool())
|
void migrateLegacyCrewToPoolIfNeeded().then(() => syncPersonPool())
|
||||||
void migrateLegacyYachtsToPoolIfNeeded().then(() => syncVesselPool())
|
void migrateLegacyYachtsToPoolIfNeeded().then(() => syncVesselPool())
|
||||||
}, [isAuthenticated])
|
void refreshAdminAccess()
|
||||||
|
}, [isAuthenticated, refreshAdminAccess])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleOnline = () => {
|
const handleOnline = () => {
|
||||||
@@ -199,6 +215,13 @@ function App() {
|
|||||||
const hashParams = new URLSearchParams(window.location.hash.substring(1))
|
const hashParams = new URLSearchParams(window.location.hash.substring(1))
|
||||||
const path = window.location.pathname
|
const path = window.location.pathname
|
||||||
|
|
||||||
|
if (path.startsWith('/admin')) {
|
||||||
|
setIsAdminRoute(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsAdminRoute(false)
|
||||||
|
|
||||||
if (path === '/demo') {
|
if (path === '/demo') {
|
||||||
setIsDemoMode(true)
|
setIsDemoMode(true)
|
||||||
setIsViewerMode(false)
|
setIsViewerMode(false)
|
||||||
@@ -240,6 +263,7 @@ function App() {
|
|||||||
|
|
||||||
const clearAuthenticatedAppState = useCallback(() => {
|
const clearAuthenticatedAppState = useCallback(() => {
|
||||||
setIsAuthenticated(false)
|
setIsAuthenticated(false)
|
||||||
|
setIsAdminUser(false)
|
||||||
setActiveLogbookId(null)
|
setActiveLogbookId(null)
|
||||||
setActiveLogbookTitle(null)
|
setActiveLogbookTitle(null)
|
||||||
setShowUserProfile(false)
|
setShowUserProfile(false)
|
||||||
@@ -249,7 +273,7 @@ function App() {
|
|||||||
|
|
||||||
/** After PWA/bfcache resume, React state may still say "logged in" while the master key is gone. */
|
/** After PWA/bfcache resume, React state may still say "logged in" while the master key is gone. */
|
||||||
const enforceUnlockedSession = useCallback(() => {
|
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.
|
// Require full local session (incl. userId) so API calls are not left headless.
|
||||||
if (isAuthenticated && !hasUnlockedLocalSession()) {
|
if (isAuthenticated && !hasUnlockedLocalSession()) {
|
||||||
clearAuthenticatedAppState()
|
clearAuthenticatedAppState()
|
||||||
@@ -259,6 +283,7 @@ function App() {
|
|||||||
isViewerMode,
|
isViewerMode,
|
||||||
isDemoMode,
|
isDemoMode,
|
||||||
isAcceptingInvite,
|
isAcceptingInvite,
|
||||||
|
isAdminRoute,
|
||||||
clearAuthenticatedAppState
|
clearAuthenticatedAppState
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -293,6 +318,8 @@ function App() {
|
|||||||
const session = await checkServerSession()
|
const session = await checkServerSession()
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
|
|
||||||
|
setServerSessionActive(session.authenticated)
|
||||||
|
|
||||||
if (session.authenticated) {
|
if (session.authenticated) {
|
||||||
persistSessionUserId(session.userId)
|
persistSessionUserId(session.userId)
|
||||||
}
|
}
|
||||||
@@ -312,6 +339,10 @@ function App() {
|
|||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
console.warn('Session restore failed:', err)
|
console.warn('Session restore failed:', err)
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setSessionChecked(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
@@ -333,6 +364,14 @@ function App() {
|
|||||||
setIsAcceptingInvite(false)
|
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) => {
|
const selectLogbook = useCallback((id: string, title: string) => {
|
||||||
setActiveLogbookId(id)
|
setActiveLogbookId(id)
|
||||||
setActiveLogbookTitle(title)
|
setActiveLogbookTitle(title)
|
||||||
@@ -497,6 +536,7 @@ function App() {
|
|||||||
if (!(await confirmLeave())) return
|
if (!(await confirmLeave())) return
|
||||||
void logoutUser()
|
void logoutUser()
|
||||||
setIsAuthenticated(false)
|
setIsAuthenticated(false)
|
||||||
|
setIsAdminUser(false)
|
||||||
setActiveLogbookId(null)
|
setActiveLogbookId(null)
|
||||||
setActiveLogbookTitle(null)
|
setActiveLogbookTitle(null)
|
||||||
setShowUserProfile(false)
|
setShowUserProfile(false)
|
||||||
@@ -524,6 +564,28 @@ function App() {
|
|||||||
syncRouteFromLocation()
|
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) {
|
if (isDemoMode) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'contents' }}>
|
<div style={{ display: 'contents' }}>
|
||||||
@@ -564,7 +626,17 @@ function App() {
|
|||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return (
|
return (
|
||||||
<div className="auth-screen">
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -574,7 +646,8 @@ function App() {
|
|||||||
const logbookReadOnly =
|
const logbookReadOnly =
|
||||||
activeLogbookRecord?.isShared === 1 && activeAccessRole === 'READ'
|
activeLogbookRecord?.isShared === 1 && activeAccessRole === 'READ'
|
||||||
const isLogbookOwner =
|
const isLogbookOwner =
|
||||||
activeAccessRole === 'OWNER' || activeLogbookRecord?.isShared !== 1
|
activeAccessRole === 'OWNER' ||
|
||||||
|
(activeLogbookRecord != null && activeLogbookRecord.isShared !== 1)
|
||||||
|
|
||||||
if (showUserProfile) {
|
if (showUserProfile) {
|
||||||
return (
|
return (
|
||||||
@@ -596,6 +669,7 @@ function App() {
|
|||||||
onSelectLogbook={selectLogbook}
|
onSelectLogbook={selectLogbook}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
onOpenProfile={() => setShowUserProfile(true)}
|
onOpenProfile={() => setShowUserProfile(true)}
|
||||||
|
onOpenAdmin={isAdminUser ? openAdmin : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -646,6 +720,8 @@ function App() {
|
|||||||
<Languages size={18} />
|
<Languages size={18} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{isAdminUser && <AdminHeaderButton onClick={openAdmin} />}
|
||||||
|
|
||||||
<ProfileHeaderButton onClick={() => setShowUserProfile(true)} />
|
<ProfileHeaderButton onClick={() => setShowUserProfile(true)} />
|
||||||
|
|
||||||
<DisclaimerHeaderButton />
|
<DisclaimerHeaderButton />
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
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, Database, Image, MapPin, Mic, Users } from 'lucide-react'
|
||||||
|
|
||||||
|
function formatNumber(value: number): string {
|
||||||
|
return value.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number | undefined): string {
|
||||||
|
if (bytes === undefined || bytes === null) return '—'
|
||||||
|
if (bytes === 0) return '0 B'
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
const num = bytes / Math.pow(k, i)
|
||||||
|
return `${num.toFixed(1)} ${sizes[i]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiCard({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
value
|
||||||
|
}: {
|
||||||
|
icon: ReactNode
|
||||||
|
label: string
|
||||||
|
value: number | string
|
||||||
|
}) {
|
||||||
|
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">{typeof value === 'number' ? formatNumber(value) : 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}
|
||||||
|
/>
|
||||||
|
<KpiCard icon={<Database size={20} />} label="Datenbankgröße" value={formatBytes(summary.dbSize)} />
|
||||||
|
</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} />
|
||||||
|
<TimeSeriesChart title="Datenbankgröße (MB)" seriesKey="database_size" 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'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
|
|
||||||
const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'
|
const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'
|
||||||
|
const KOFI_URL = 'https://ko-fi.com/kapteinsdaagbok'
|
||||||
|
|
||||||
export default function AppFooter() {
|
export default function AppFooter() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="app-version-footer">
|
<footer className="app-version-footer">
|
||||||
<span className="app-version-footer__version">v{APP_VERSION}</span>
|
<span className="app-version-footer__version">v{APP_VERSION}</span>
|
||||||
@@ -10,14 +15,47 @@ export default function AppFooter() {
|
|||||||
·
|
·
|
||||||
</span>
|
</span>
|
||||||
<span className="app-version-footer__copyright">
|
<span className="app-version-footer__copyright">
|
||||||
© 2026 KnorrLabs/
|
© 2026
|
||||||
<a
|
|
||||||
href="mailto:elpatron+kd@mailbox.org"
|
|
||||||
onClick={() => trackPlausibleEvent(PlausibleEvents.FOOTER_LINK_CLICKED)}
|
|
||||||
>
|
|
||||||
Markus F.J. Busche
|
|
||||||
</a>
|
|
||||||
</span>
|
</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>
|
</footer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
|
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
|
||||||
import {
|
import {
|
||||||
@@ -12,7 +12,8 @@ import {
|
|||||||
getKnownUsernames,
|
getKnownUsernames,
|
||||||
forgetUsername,
|
forgetUsername,
|
||||||
hasUnlockedLocalSession,
|
hasUnlockedLocalSession,
|
||||||
logoutUser
|
logoutUser,
|
||||||
|
resolveRestoreUsername
|
||||||
} from '../services/auth.js'
|
} from '../services/auth.js'
|
||||||
import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react'
|
import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react'
|
||||||
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
|
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
|
||||||
@@ -27,9 +28,15 @@ import {
|
|||||||
interface AuthOnboardingProps {
|
interface AuthOnboardingProps {
|
||||||
onAuthenticated: () => void
|
onAuthenticated: () => void
|
||||||
onOpenDemo?: () => 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 { t, i18n } = useTranslation()
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -60,7 +67,10 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
|||||||
const [isNewRegistration, setIsNewRegistration] = useState(false)
|
const [isNewRegistration, setIsNewRegistration] = useState(false)
|
||||||
const [showDisclaimer, setShowDisclaimer] = useState(false)
|
const [showDisclaimer, setShowDisclaimer] = useState(false)
|
||||||
const [showHelp, setShowHelp] = useState(false)
|
const [showHelp, setShowHelp] = useState(false)
|
||||||
|
const [showStandardLogin, setShowStandardLogin] = useState(false)
|
||||||
|
const autoUnlockAttempted = useRef(false)
|
||||||
|
|
||||||
|
const isRestoreFlow = restoreSession && !showStandardLogin
|
||||||
const passkeyHostOk = isPasskeyCompatibleLocation()
|
const passkeyHostOk = isPasskeyCompatibleLocation()
|
||||||
const passkeyCompatibleUrl = passkeyHostOk ? null : toPasskeyCompatibleUrl(window.location.href)
|
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) => {
|
const handleRecoverySubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!recoveryInput.trim() || !encryptedPayloads) return
|
if (!recoveryInput.trim() || !encryptedPayloads) return
|
||||||
@@ -347,10 +374,10 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
|||||||
<div className="auth-card glass">
|
<div className="auth-card glass">
|
||||||
<div className="auth-header">
|
<div className="auth-header">
|
||||||
<KeyRound className="auth-icon accent" size={48} />
|
<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>
|
</div>
|
||||||
<p className="recovery-warning">
|
<p className="recovery-warning">
|
||||||
{t('auth.enter_pin_warning')}
|
{isRestoreFlow ? t('auth.restore_pin_warning') : t('auth.enter_pin_warning')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form onSubmit={handlePinLoginSubmit} className="auth-form">
|
<form onSubmit={handlePinLoginSubmit} className="auth-form">
|
||||||
@@ -397,6 +424,12 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
|||||||
type="button"
|
type="button"
|
||||||
className="btn secondary"
|
className="btn secondary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (isRestoreFlow) {
|
||||||
|
setShowPinLogin(false)
|
||||||
|
setPinLoginInput('')
|
||||||
|
setError(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
void (async () => {
|
void (async () => {
|
||||||
setShowPinLogin(false)
|
setShowPinLogin(false)
|
||||||
setPinLoginInput('')
|
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
|
// Render 3: Standard Login / Registration options form
|
||||||
return (
|
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">
|
<button className="btn-icon" onClick={() => openEditMember(m)} title="Edit">
|
||||||
<Edit2 size={14} />
|
<Edit2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
<button className="btn-icon logout" onClick={() => handleDeleteMember(m.payloadId)} title="Delete">
|
<button className="btn-icon danger" onClick={() => handleDeleteMember(m.payloadId)} title="Delete">
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
|||||||
preloadedYacht={yacht}
|
preloadedYacht={yacht}
|
||||||
preloadedEntries={entries}
|
preloadedEntries={entries}
|
||||||
preloadedPhotos={photos}
|
preloadedPhotos={photos}
|
||||||
|
preloadedVoiceMemos={[]}
|
||||||
preloadedGpsTracks={gpsTracks}
|
preloadedGpsTracks={gpsTracks}
|
||||||
controlledSelectedEntryId={tourSelectedEntryId}
|
controlledSelectedEntryId={tourSelectedEntryId}
|
||||||
onSelectedEntryIdChange={setTourSelectedEntryId}
|
onSelectedEntryIdChange={setTourSelectedEntryId}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { getLogbookKey } from '../services/logbookKeys.js'
|
|||||||
import { encryptJson, decryptJson } from '../services/crypto.js'
|
import { encryptJson, decryptJson } from '../services/crypto.js'
|
||||||
import { syncLogbook } from '../services/sync.js'
|
import { syncLogbook } from '../services/sync.js'
|
||||||
import { Compass, Save, Check } from 'lucide-react'
|
import { Compass, Save, Check } from 'lucide-react'
|
||||||
|
import { parseAppDecimalOrZero } from '../utils/numberFormat.js'
|
||||||
|
|
||||||
interface DeviationFormProps {
|
interface DeviationFormProps {
|
||||||
logbookId: string
|
logbookId: string
|
||||||
@@ -97,8 +98,8 @@ export default function DeviationForm({ logbookId, readOnly = false, preloadedDa
|
|||||||
const sanitizedDeviations: Record<number, number> = {}
|
const sanitizedDeviations: Record<number, number> = {}
|
||||||
headings.forEach((h) => {
|
headings.forEach((h) => {
|
||||||
const val = deviations[h] || ''
|
const val = deviations[h] || ''
|
||||||
const parsed = parseFloat(val.replace('+', '').trim())
|
const parsed = parseAppDecimalOrZero(val.replace('+', '').trim())
|
||||||
sanitizedDeviations[h] = isNaN(parsed) ? 0 : parsed
|
sanitizedDeviations[h] = parsed
|
||||||
})
|
})
|
||||||
|
|
||||||
const dataToSave = {
|
const dataToSave = {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Users } from 'lucide-react'
|
import { Users, ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
import type { EntryCrewFields, PersonSnapshot } from '../types/person.js'
|
import type { EntryCrewFields, PersonSnapshot } from '../types/person.js'
|
||||||
import { loadPersonPool } from '../services/personPool.js'
|
import { loadPersonPool } from '../services/personPool.js'
|
||||||
import { loadLogbookCrewSelection } from '../services/logbookCrewSelection.js'
|
import { loadLogbookCrewSelection } from '../services/logbookCrewSelection.js'
|
||||||
@@ -24,6 +24,7 @@ export default function EntryCrewSection({
|
|||||||
preloadedPool
|
preloadedPool
|
||||||
}: EntryCrewSectionProps) {
|
}: EntryCrewSectionProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const [collapsed, setCollapsed] = useState(true)
|
||||||
const [pool, setPool] = useState<Map<string, PersonData>>(preloadedPool ?? new Map())
|
const [pool, setPool] = useState<Map<string, PersonData>>(preloadedPool ?? new Map())
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -90,54 +91,78 @@ export default function EntryCrewSection({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="form-card" data-tour="entry-crew">
|
<div className="form-card" data-tour="entry-crew">
|
||||||
<div className="form-header">
|
<div
|
||||||
<Users size={22} className="form-icon" />
|
className="form-header accordion-header"
|
||||||
<h3>{t('entry_crew.title')}</h3>
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
</div>
|
onKeyDown={(e) => {
|
||||||
<p className="help-text mb-3">{t('entry_crew.subtitle')}</p>
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
<div className="input-group mb-3">
|
setCollapsed(!collapsed)
|
||||||
<label>{t('entry_crew.day_skipper')}</label>
|
}
|
||||||
{skippers.length === 0 ? (
|
}}
|
||||||
<p className="help-text">{t('entry_crew.no_skipper')}</p>
|
role="button"
|
||||||
|
aria-expanded={!collapsed}
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<div className="accordion-header-title">
|
||||||
|
<Users size={22} className="form-icon" />
|
||||||
|
<h3>{t('entry_crew.title')}</h3>
|
||||||
|
</div>
|
||||||
|
{collapsed ? (
|
||||||
|
<ChevronDown size={20} className="accordion-chevron" />
|
||||||
) : (
|
) : (
|
||||||
<div className="crew-selection-list">
|
<ChevronUp size={20} className="accordion-chevron" />
|
||||||
{skippers.map(([id, data]) => (
|
|
||||||
<label key={id} className="crew-selection-item">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name={`entry-skipper-${logbookId}`}
|
|
||||||
checked={value.selectedSkipperId === id}
|
|
||||||
onChange={() => !readOnly && applyChange(id, value.selectedCrewIds)}
|
|
||||||
disabled={readOnly}
|
|
||||||
/>
|
|
||||||
<span>{data.name || t('logbook_crew.unnamed')}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="input-group">
|
{!collapsed && (
|
||||||
<label>{t('entry_crew.day_crew')}</label>
|
<>
|
||||||
{crewEntries.length === 0 ? (
|
<p className="help-text mb-3" style={{ marginTop: '16px' }}>{t('entry_crew.subtitle')}</p>
|
||||||
<p className="help-text">{t('entry_crew.no_crew')}</p>
|
|
||||||
) : (
|
<div className="input-group mb-3">
|
||||||
<div className="crew-selection-list">
|
<label>{t('entry_crew.day_skipper')}</label>
|
||||||
{crewEntries.map(([id, data]) => (
|
{skippers.length === 0 ? (
|
||||||
<label key={id} className="crew-selection-item">
|
<p className="help-text">{t('entry_crew.no_skipper')}</p>
|
||||||
<input
|
) : (
|
||||||
type="checkbox"
|
<div className="crew-selection-list">
|
||||||
checked={value.selectedCrewIds.includes(id)}
|
{skippers.map(([id, data]) => (
|
||||||
onChange={() => toggleCrew(id)}
|
<label key={id} className="crew-selection-item">
|
||||||
disabled={readOnly}
|
<input
|
||||||
/>
|
type="radio"
|
||||||
<span>{data.name || t('logbook_crew.unnamed')}</span>
|
name={`entry-skipper-${logbookId}`}
|
||||||
</label>
|
checked={value.selectedSkipperId === id}
|
||||||
))}
|
onChange={() => !readOnly && applyChange(id, value.selectedCrewIds)}
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
<span>{data.name || t('logbook_crew.unnamed')}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
<div className="input-group">
|
||||||
|
<label>{t('entry_crew.day_crew')}</label>
|
||||||
|
{crewEntries.length === 0 ? (
|
||||||
|
<p className="help-text">{t('entry_crew.no_crew')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="crew-selection-list">
|
||||||
|
{crewEntries.map(([id, data]) => (
|
||||||
|
<label key={id} className="crew-selection-item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={value.selectedCrewIds.includes(id)}
|
||||||
|
onChange={() => toggleCrew(id)}
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
<span>{data.name || t('logbook_crew.unnamed')}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { useId, useMemo } from 'react'
|
||||||
import { joinTimeHHMM, splitTimeHHMM } from '../utils/logEntryPayload.js'
|
import { joinTimeHHMM, splitTimeHHMM } from '../utils/logEntryPayload.js'
|
||||||
|
import { preferNativeCameraPicker } from '../utils/captureVideoFrame.js'
|
||||||
|
|
||||||
const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'))
|
const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'))
|
||||||
const MINUTES = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'))
|
const MINUTES = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'))
|
||||||
@@ -18,7 +19,29 @@ export default function EventTimeInput24h({
|
|||||||
'aria-label': ariaLabel
|
'aria-label': ariaLabel
|
||||||
}: EventTimeInput24hProps) {
|
}: EventTimeInput24hProps) {
|
||||||
const baseId = useId()
|
const baseId = useId()
|
||||||
|
const useNativePicker = preferNativeCameraPicker()
|
||||||
const { hours, minutes } = useMemo(() => splitTimeHHMM(value), [value])
|
const { hours, minutes } = useMemo(() => splitTimeHHMM(value), [value])
|
||||||
|
const timeValue = useMemo(() => joinTimeHHMM(hours, minutes), [hours, minutes])
|
||||||
|
|
||||||
|
if (useNativePicker) {
|
||||||
|
return (
|
||||||
|
<div className="time-input-24h">
|
||||||
|
<input
|
||||||
|
id={baseId}
|
||||||
|
type="time"
|
||||||
|
step={60}
|
||||||
|
className="input-text time-input-24h__native"
|
||||||
|
value={timeValue}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = e.target.value
|
||||||
|
if (next) onChange(next.slice(0, 5))
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="time-input-24h">
|
<div className="time-input-24h">
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Signal } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
formatGpsAccuracyMeters,
|
||||||
|
gpsQualityI18nKey,
|
||||||
|
type GpsSignalQuality
|
||||||
|
} from '../utils/geolocation.js'
|
||||||
|
|
||||||
|
const SIGNAL_BARS: Record<GpsSignalQuality, number> = {
|
||||||
|
excellent: 4,
|
||||||
|
good: 3,
|
||||||
|
fair: 2,
|
||||||
|
poor: 1,
|
||||||
|
unknown: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GpsSignalHintProps {
|
||||||
|
quality: GpsSignalQuality
|
||||||
|
accuracyM: number | null
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GpsSignalHint({ quality, accuracyM, className = '' }: GpsSignalHintProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const bars = SIGNAL_BARS[quality]
|
||||||
|
const i18nParams = accuracyM != null ? { accuracy: formatGpsAccuracyMeters(accuracyM) } : undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
className={`gps-signal-hint gps-signal-${quality} ${className}`.trim()}
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<span className="gps-signal-hint-label">
|
||||||
|
<Signal size={14} aria-hidden className="gps-signal-icon" />
|
||||||
|
<span className="gps-signal-bars" aria-hidden>
|
||||||
|
{[1, 2, 3, 4].map((level) => (
|
||||||
|
<span
|
||||||
|
key={level}
|
||||||
|
className={`gps-signal-bar ${level <= bars ? 'is-active' : ''}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<span>{t(gpsQualityI18nKey(quality), i18nParams)}</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Camera, X } from 'lucide-react'
|
import { Camera, X } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
cameraErrorKeyFromDomException,
|
||||||
|
probeCameraAvailability
|
||||||
|
} from '../utils/cameraAvailability.js'
|
||||||
import {
|
import {
|
||||||
captureVideoFrame,
|
captureVideoFrame,
|
||||||
preferNativeCameraPicker
|
preferNativeCameraPicker
|
||||||
@@ -15,7 +19,7 @@ interface LiveCameraCaptureProps {
|
|||||||
onCapture: (blob: Blob) => void
|
onCapture: (blob: Blob) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type Phase = 'live' | 'preview' | 'native'
|
type Phase = 'checking' | 'live' | 'preview' | 'native'
|
||||||
|
|
||||||
export default function LiveCameraCapture({
|
export default function LiveCameraCapture({
|
||||||
open,
|
open,
|
||||||
@@ -34,7 +38,7 @@ export default function LiveCameraCapture({
|
|||||||
const [cameraError, setCameraError] = useState<string | null>(null)
|
const [cameraError, setCameraError] = useState<string | null>(null)
|
||||||
const [ready, setReady] = useState(false)
|
const [ready, setReady] = useState(false)
|
||||||
const [capturing, setCapturing] = useState(false)
|
const [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 [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||||
const [previewBlob, setPreviewBlob] = useState<Blob | null>(null)
|
const [previewBlob, setPreviewBlob] = useState<Blob | null>(null)
|
||||||
const [streamGeneration, setStreamGeneration] = useState(0)
|
const [streamGeneration, setStreamGeneration] = useState(0)
|
||||||
@@ -87,12 +91,37 @@ export default function LiveCameraCapture({
|
|||||||
clearPreview()
|
clearPreview()
|
||||||
setCameraError(null)
|
setCameraError(null)
|
||||||
setCapturing(false)
|
setCapturing(false)
|
||||||
setPhase(preferNativeCameraPicker() ? 'native' : 'live')
|
setPhase('checking')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setPhase(preferNativeCameraPicker() ? 'native' : 'live')
|
|
||||||
|
let cancelled = false
|
||||||
clearPreview()
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open || phase !== 'live') {
|
if (!open || phase !== 'live') {
|
||||||
@@ -105,11 +134,6 @@ export default function LiveCameraCapture({
|
|||||||
const start = async () => {
|
const start = async () => {
|
||||||
setCameraError(null)
|
setCameraError(null)
|
||||||
setReady(false)
|
setReady(false)
|
||||||
if (!navigator.mediaDevices?.getUserMedia) {
|
|
||||||
setCameraError(t('logs.live_photo_camera_unavailable'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
video: {
|
video: {
|
||||||
@@ -141,7 +165,7 @@ export default function LiveCameraCapture({
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Camera access failed:', err)
|
console.error('Camera access failed:', err)
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setCameraError(t('logs.live_photo_camera_denied'))
|
setCameraError(t(cameraErrorKeyFromDomException(err)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -216,7 +240,7 @@ export default function LiveCameraCapture({
|
|||||||
className="btn secondary live-camera-close"
|
className="btn secondary live-camera-close"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
aria-label={t('logs.confirm_no')}
|
aria-label={t('logs.live_cancel')}
|
||||||
>
|
>
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
</button>
|
</button>
|
||||||
@@ -243,6 +267,12 @@ export default function LiveCameraCapture({
|
|||||||
className="live-camera-preview live-camera-preview-still"
|
className="live-camera-preview live-camera-preview-still"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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' ? (
|
) : phase === 'native' ? (
|
||||||
<div className="live-camera-native-prompt">
|
<div className="live-camera-native-prompt">
|
||||||
<p className="live-log-modal-hint">{t('logs.live_photo_native_hint')}</p>
|
<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')}
|
{t('logs.live_photo_open_camera_btn')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : cameraError && !ready ? null : (
|
) : phase === 'live' ? (
|
||||||
<div className="live-camera-preview-wrap">
|
<div className="live-camera-preview-wrap">
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
@@ -269,7 +299,7 @@ export default function LiveCameraCapture({
|
|||||||
<p className="live-camera-loading">{t('logs.live_photo_camera_starting')}</p>
|
<p className="live-camera-loading">{t('logs.live_photo_camera_starting')}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
{onCaptionChange && (
|
{onCaptionChange && (
|
||||||
<div className="input-group live-camera-caption">
|
<div className="input-group live-camera-caption">
|
||||||
@@ -287,7 +317,7 @@ export default function LiveCameraCapture({
|
|||||||
|
|
||||||
<div className="live-log-modal-actions live-camera-actions">
|
<div className="live-log-modal-actions live-camera-actions">
|
||||||
<button type="button" className="btn secondary" onClick={onClose} disabled={busy}>
|
<button type="button" className="btn secondary" onClick={onClose} disabled={busy}>
|
||||||
{t('logs.confirm_no')}
|
{t('logs.live_cancel')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showPreview ? (
|
{showPreview ? (
|
||||||
|
|||||||
@@ -15,31 +15,34 @@ import {
|
|||||||
MapPin,
|
MapPin,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Camera,
|
Camera,
|
||||||
|
Mic,
|
||||||
Radio,
|
Radio,
|
||||||
Sailboat,
|
Sailboat,
|
||||||
Undo2,
|
Undo2,
|
||||||
Zap
|
Zap
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
|
import { getAiAuthorized } from '../services/userPreferences.js'
|
||||||
import {
|
import {
|
||||||
appendQuickEvent,
|
appendQuickEvent as apiAppendQuickEvent,
|
||||||
appendQuickEvents,
|
appendQuickEvents as apiAppendQuickEvents,
|
||||||
appendTankRefill,
|
appendTankRefill as apiAppendTankRefill,
|
||||||
findOrCreateTodayEntry,
|
findOrCreateTodayEntry,
|
||||||
loadEntry,
|
loadEntry,
|
||||||
removeLastEvent
|
removeLastEvent
|
||||||
} from '../services/quickEventLog.js'
|
} from '../services/quickEventLog.js'
|
||||||
import { formatEventSummary } from '../utils/formatEventSummary.js'
|
import CreatorAvatar from './CreatorAvatar.tsx'
|
||||||
import {
|
import {
|
||||||
getLastAutoPositionMs,
|
getLastAutoPositionMs,
|
||||||
getLastPositionFixWithin,
|
getLastLoggedPositionWithin,
|
||||||
getLatestPositionFix,
|
getLatestLoggedPosition,
|
||||||
isMotorRunningFromEvents,
|
isMotorRunningFromEvents,
|
||||||
LIVE_EVENT_CODES,
|
LIVE_EVENT_CODES,
|
||||||
LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS,
|
LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS,
|
||||||
liveCommentRemark,
|
liveCommentRemark,
|
||||||
liveFuelRemark,
|
liveFuelRemark,
|
||||||
livePhotoRemark,
|
livePhotoRemark,
|
||||||
|
liveVoiceRemark,
|
||||||
livePrecipRemark,
|
livePrecipRemark,
|
||||||
liveSailsRemark,
|
liveSailsRemark,
|
||||||
liveSogRemark,
|
liveSogRemark,
|
||||||
@@ -47,12 +50,22 @@ import {
|
|||||||
liveTempRemark,
|
liveTempRemark,
|
||||||
liveWaterRemark
|
liveWaterRemark
|
||||||
} from '../utils/liveEventCodes.js'
|
} from '../utils/liveEventCodes.js'
|
||||||
|
import { formatAppDecimal, formatTankLiters, parseAppDecimal } from '../utils/numberFormat.js'
|
||||||
|
|
||||||
|
const formatSpeedKn = (speedKn: number) =>
|
||||||
|
formatAppDecimal(speedKn, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
||||||
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
|
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
|
||||||
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
|
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
|
||||||
import {
|
import {
|
||||||
|
geolocationErrorI18nKey,
|
||||||
getCurrentPosition,
|
getCurrentPosition,
|
||||||
|
getGeolocationErrorReason,
|
||||||
|
hasSeenGeolocationLiveIntro,
|
||||||
|
markGeolocationLiveIntroSeen,
|
||||||
normalizeGpsCoordinates,
|
normalizeGpsCoordinates,
|
||||||
queryGeolocationPermission
|
queryGeolocationPermission,
|
||||||
|
type GeolocationErrorReason,
|
||||||
|
type GpsSignalQuality
|
||||||
} from '../utils/geolocation.js'
|
} from '../utils/geolocation.js'
|
||||||
import { sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayload.js'
|
import { sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayload.js'
|
||||||
import {
|
import {
|
||||||
@@ -63,9 +76,15 @@ import {
|
|||||||
} from '../utils/sailSelection.js'
|
} from '../utils/sailSelection.js'
|
||||||
import { useDialog } from './ModalDialog.tsx'
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
import CourseDialInput from './CourseDialInput.tsx'
|
import CourseDialInput from './CourseDialInput.tsx'
|
||||||
|
import GpsSignalHint from './GpsSignalHint.tsx'
|
||||||
import LiveCameraCapture from './LiveCameraCapture.tsx'
|
import LiveCameraCapture from './LiveCameraCapture.tsx'
|
||||||
|
import LiveVoiceCapture from './LiveVoiceCapture.tsx'
|
||||||
|
import EventRemarksCell from './EventRemarksCell.tsx'
|
||||||
import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.js'
|
import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.js'
|
||||||
|
import { saveEntryVoiceMemo, deleteEntryVoiceMemo } from '../services/voiceAttachments.js'
|
||||||
import { blobToCompressedJpegDataUrl } from '../utils/imageCompress.js'
|
import { blobToCompressedJpegDataUrl } from '../utils/imageCompress.js'
|
||||||
|
import { blobToAudioDataUrl } from '../utils/audioBlob.js'
|
||||||
|
import { useEntryVoiceMemos } from '../hooks/useEntryVoiceMemos.js'
|
||||||
|
|
||||||
interface LiveLogViewProps {
|
interface LiveLogViewProps {
|
||||||
logbookId: string
|
logbookId: string
|
||||||
@@ -88,8 +107,9 @@ type LiveModal =
|
|||||||
| 'water'
|
| 'water'
|
||||||
| 'sog'
|
| 'sog'
|
||||||
| 'stw'
|
| 'stw'
|
||||||
| 'fix'
|
| 'position'
|
||||||
| 'photo'
|
| 'photo'
|
||||||
|
| 'voice'
|
||||||
|
|
||||||
const AUTO_POSITION_INTERVAL_MS = 3 * 60 * 60 * 1000
|
const AUTO_POSITION_INTERVAL_MS = 3 * 60 * 60 * 1000
|
||||||
const AUTO_POSITION_CHECK_MS = 60_000
|
const AUTO_POSITION_CHECK_MS = 60_000
|
||||||
@@ -133,18 +153,46 @@ function lastWindDirectionFromEvents(events: LogEventPayload[]): string {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function gpsFailureAlertBody(
|
||||||
|
t: (key: string) => string,
|
||||||
|
reason: GeolocationErrorReason
|
||||||
|
): string {
|
||||||
|
return `${t(geolocationErrorI18nKey(reason))}\n\n${t('logs.live_position_manual_hint')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function findActiveCreatorId(
|
||||||
|
activeUsername: string | null,
|
||||||
|
crewSnapshotsById: Record<string, any>,
|
||||||
|
selectedSkipperId: string | null
|
||||||
|
): string {
|
||||||
|
const username = (activeUsername || '').trim()
|
||||||
|
if (username) {
|
||||||
|
const matchEntry = Object.entries(crewSnapshotsById).find(
|
||||||
|
([_, snap]) => (snap?.name || '').trim().toLowerCase() === username.toLowerCase()
|
||||||
|
)
|
||||||
|
if (matchEntry) {
|
||||||
|
return matchEntry[0]
|
||||||
|
}
|
||||||
|
return username
|
||||||
|
}
|
||||||
|
return selectedSkipperId || 'skipper'
|
||||||
|
}
|
||||||
|
|
||||||
export default function LiveLogView({
|
export default function LiveLogView({
|
||||||
logbookId,
|
logbookId,
|
||||||
onOpenEditor,
|
onOpenEditor,
|
||||||
onSwitchToList
|
onSwitchToList
|
||||||
}: LiveLogViewProps) {
|
}: LiveLogViewProps) {
|
||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
const { showAlert } = useDialog()
|
const { showAlert, showConfirm } = useDialog()
|
||||||
|
const [geolocationAccessEpoch, setGeolocationAccessEpoch] = useState(0)
|
||||||
|
|
||||||
const [entryId, setEntryId] = useState<string | null>(null)
|
const [entryId, setEntryId] = useState<string | null>(null)
|
||||||
const [dayOfTravel, setDayOfTravel] = useState('')
|
const [dayOfTravel, setDayOfTravel] = useState('')
|
||||||
const [date, setDate] = useState('')
|
const [date, setDate] = useState('')
|
||||||
const [events, setEvents] = useState<LogEventPayload[]>([])
|
const [events, setEvents] = useState<LogEventPayload[]>([])
|
||||||
|
const [crewSnapshotsById, setCrewSnapshotsById] = useState<Record<string, any>>({})
|
||||||
|
const [selectedSkipperId, setSelectedSkipperId] = useState<string | null>(null)
|
||||||
const [yachtSails, setYachtSails] = useState<string[]>([])
|
const [yachtSails, setYachtSails] = useState<string[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
@@ -152,21 +200,30 @@ export default function LiveLogView({
|
|||||||
const [modal, setModal] = useState<LiveModal>('none')
|
const [modal, setModal] = useState<LiveModal>('none')
|
||||||
const [weatherExpanded, setWeatherExpanded] = useState(false)
|
const [weatherExpanded, setWeatherExpanded] = useState(false)
|
||||||
const [weatherOwmLoading, setWeatherOwmLoading] = useState(false)
|
const [weatherOwmLoading, setWeatherOwmLoading] = useState(false)
|
||||||
|
const [isOnline, setIsOnline] = useState(navigator.onLine)
|
||||||
const [commentText, setCommentText] = useState('')
|
const [commentText, setCommentText] = useState('')
|
||||||
const [valueInput, setValueInput] = useState('')
|
const [valueInput, setValueInput] = useState('')
|
||||||
const [valueInputSecondary, setValueInputSecondary] = useState('')
|
const [valueInputSecondary, setValueInputSecondary] = useState('')
|
||||||
const [selectedSails, setSelectedSails] = useState<string[]>([])
|
const [selectedSails, setSelectedSails] = useState<string[]>([])
|
||||||
const [undoVisible, setUndoVisible] = useState(false)
|
const [undoVisible, setUndoVisible] = useState(false)
|
||||||
const [fixLat, setFixLat] = useState('')
|
const [positionLat, setPositionLat] = useState('')
|
||||||
const [fixLng, setFixLng] = useState('')
|
const [positionLng, setPositionLng] = useState('')
|
||||||
const [fixGpsLoading, setFixGpsLoading] = useState(false)
|
const [positionGpsLoading, setPositionGpsLoading] = useState(false)
|
||||||
const [fixGpsUnavailable, setFixGpsUnavailable] = useState(false)
|
const [positionGpsUnavailable, setPositionGpsUnavailable] = useState(false)
|
||||||
|
const [positionGpsErrorReason, setPositionGpsErrorReason] = useState<GeolocationErrorReason | null>(null)
|
||||||
|
const [positionGpsSignal, setPositionGpsSignal] = useState<{
|
||||||
|
quality: GpsSignalQuality
|
||||||
|
accuracyM: number | null
|
||||||
|
} | null>(null)
|
||||||
const [photoCaption, setPhotoCaption] = useState('')
|
const [photoCaption, setPhotoCaption] = useState('')
|
||||||
const [photoSaving, setPhotoSaving] = useState(false)
|
const [photoSaving, setPhotoSaving] = useState(false)
|
||||||
const [undoHint, setUndoHint] = useState<'event' | 'photo'>('event')
|
const [voiceCaption, setVoiceCaption] = useState('')
|
||||||
|
const [voiceSaving, setVoiceSaving] = useState(false)
|
||||||
|
const [undoHint, setUndoHint] = useState<'event' | 'photo' | 'voice'>('event')
|
||||||
|
|
||||||
const streamEndRef = useRef<HTMLDivElement | null>(null)
|
const streamEndRef = useRef<HTMLDivElement | null>(null)
|
||||||
const undoPhotoIdRef = useRef<string | null>(null)
|
const undoPhotoIdRef = useRef<string | null>(null)
|
||||||
|
const undoVoiceIdRef = useRef<string | null>(null)
|
||||||
const undoTimerRef = useRef<number | null>(null)
|
const undoTimerRef = useRef<number | null>(null)
|
||||||
const autoPositionBusyRef = useRef(false)
|
const autoPositionBusyRef = useRef(false)
|
||||||
const busyRef = useRef(busy)
|
const busyRef = useRef(busy)
|
||||||
@@ -177,6 +234,51 @@ export default function LiveLogView({
|
|||||||
dateRef.current = date
|
dateRef.current = date
|
||||||
busyRef.current = busy
|
busyRef.current = busy
|
||||||
|
|
||||||
|
const getActiveCreatorId = useCallback(() => {
|
||||||
|
const activeUsername = localStorage.getItem('active_username')
|
||||||
|
return findActiveCreatorId(activeUsername, crewSnapshotsById, selectedSkipperId)
|
||||||
|
}, [crewSnapshotsById, selectedSkipperId])
|
||||||
|
|
||||||
|
const appendQuickEvent = useCallback((
|
||||||
|
logbookId: string,
|
||||||
|
entryId: string,
|
||||||
|
partialEvent: Partial<LogEventPayload>,
|
||||||
|
headerPatch?: { departure?: string; destination?: string }
|
||||||
|
) => {
|
||||||
|
return apiAppendQuickEvent(
|
||||||
|
logbookId,
|
||||||
|
entryId,
|
||||||
|
{ creatorId: getActiveCreatorId(), ...partialEvent },
|
||||||
|
headerPatch
|
||||||
|
)
|
||||||
|
}, [getActiveCreatorId])
|
||||||
|
|
||||||
|
const appendQuickEvents = useCallback((
|
||||||
|
logbookId: string,
|
||||||
|
entryId: string,
|
||||||
|
partialEvents: Partial<LogEventPayload>[]
|
||||||
|
) => {
|
||||||
|
const creatorId = getActiveCreatorId()
|
||||||
|
const mapped = partialEvents.map((p) => ({ creatorId, ...p }))
|
||||||
|
return apiAppendQuickEvents(logbookId, entryId, mapped)
|
||||||
|
}, [getActiveCreatorId])
|
||||||
|
|
||||||
|
const appendTankRefill = useCallback((
|
||||||
|
logbookId: string,
|
||||||
|
entryId: string,
|
||||||
|
tank: 'fuel' | 'freshwater',
|
||||||
|
addLiters: number,
|
||||||
|
event: Partial<LogEventPayload>
|
||||||
|
) => {
|
||||||
|
return apiAppendTankRefill(
|
||||||
|
logbookId,
|
||||||
|
entryId,
|
||||||
|
tank,
|
||||||
|
addLiters,
|
||||||
|
{ creatorId: getActiveCreatorId(), ...event }
|
||||||
|
)
|
||||||
|
}, [getActiveCreatorId])
|
||||||
|
|
||||||
const defaultSails = useMemo(
|
const defaultSails = useMemo(
|
||||||
() => (i18n.language === 'de'
|
() => (i18n.language === 'de'
|
||||||
? ['Großsegel', 'Genua', 'Fock', 'Spinnaker', 'Gennaker']
|
? ['Großsegel', 'Genua', 'Fock', 'Spinnaker', 'Gennaker']
|
||||||
@@ -189,16 +291,19 @@ export default function LiveLogView({
|
|||||||
)
|
)
|
||||||
const motorRunning = isMotorRunningFromEvents(events)
|
const motorRunning = isMotorRunningFromEvents(events)
|
||||||
const motorLabel = t('logs.motor_propulsion')
|
const motorLabel = t('logs.motor_propulsion')
|
||||||
const hasPositionFix = useMemo(
|
const hasLoggedPosition = useMemo(
|
||||||
() => (date ? getLatestPositionFix(events, date) != null : false),
|
() => (date ? getLatestLoggedPosition(events, date) != null : false),
|
||||||
[events, date]
|
[events, date]
|
||||||
)
|
)
|
||||||
|
const voiceMemoLookup = useEntryVoiceMemos(logbookId, entryId)
|
||||||
|
|
||||||
const applyLoadedEntry = useCallback((loaded: NonNullable<Awaited<ReturnType<typeof loadEntry>>>) => {
|
const applyLoadedEntry = useCallback((loaded: NonNullable<Awaited<ReturnType<typeof loadEntry>>>) => {
|
||||||
const entryEvents = (loaded.data.events as LogEventPayload[]) || []
|
const entryEvents = (loaded.data.events as LogEventPayload[]) || []
|
||||||
setDayOfTravel(String(loaded.data.dayOfTravel || ''))
|
setDayOfTravel(String(loaded.data.dayOfTravel || ''))
|
||||||
setDate(String(loaded.data.date || ''))
|
setDate(String(loaded.data.date || ''))
|
||||||
setEvents(sortLogEventsByTime(entryEvents.map((e) => ({ ...e }))))
|
setEvents(sortLogEventsByTime(entryEvents.map((e) => ({ ...e }))))
|
||||||
|
setCrewSnapshotsById((loaded.data.crewSnapshotsById as Record<string, any>) || {})
|
||||||
|
setSelectedSkipperId(typeof loaded.data.selectedSkipperId === 'string' ? loaded.data.selectedSkipperId : null)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const refreshEntry = useCallback(async (id: string) => {
|
const refreshEntry = useCallback(async (id: string) => {
|
||||||
@@ -207,7 +312,7 @@ export default function LiveLogView({
|
|||||||
applyLoadedEntry(loaded)
|
applyLoadedEntry(loaded)
|
||||||
}, [logbookId, applyLoadedEntry])
|
}, [logbookId, applyLoadedEntry])
|
||||||
|
|
||||||
const showUndo = useCallback((hint: 'event' | 'photo' = 'event') => {
|
const showUndo = useCallback((hint: 'event' | 'photo' | 'voice' = 'event') => {
|
||||||
setUndoHint(hint)
|
setUndoHint(hint)
|
||||||
setUndoVisible(true)
|
setUndoVisible(true)
|
||||||
if (undoTimerRef.current) window.clearTimeout(undoTimerRef.current)
|
if (undoTimerRef.current) window.clearTimeout(undoTimerRef.current)
|
||||||
@@ -215,6 +320,7 @@ export default function LiveLogView({
|
|||||||
setUndoVisible(false)
|
setUndoVisible(false)
|
||||||
undoTimerRef.current = null
|
undoTimerRef.current = null
|
||||||
undoPhotoIdRef.current = null
|
undoPhotoIdRef.current = null
|
||||||
|
undoVoiceIdRef.current = null
|
||||||
}, UNDO_TIMEOUT_MS)
|
}, UNDO_TIMEOUT_MS)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -269,6 +375,17 @@ export default function LiveLogView({
|
|||||||
}
|
}
|
||||||
}, [logbookId, applyLoadedEntry, t])
|
}, [logbookId, applyLoadedEntry, t])
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void runInit()
|
void runInit()
|
||||||
return () => {
|
return () => {
|
||||||
@@ -284,6 +401,56 @@ export default function LiveLogView({
|
|||||||
}
|
}
|
||||||
}, [loading, entryId])
|
}, [loading, entryId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading || !entryId || !navigator.geolocation) return
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
const permission = await queryGeolocationPermission()
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
|
if (permission === 'granted') {
|
||||||
|
markGeolocationLiveIntroSeen()
|
||||||
|
setGeolocationAccessEpoch((n) => n + 1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only ask when the browser has not granted location yet (state "prompt").
|
||||||
|
if (permission !== 'prompt' || hasSeenGeolocationLiveIntro()) return
|
||||||
|
|
||||||
|
const allow = await showConfirm(
|
||||||
|
t('logs.gps_live_intro_body'),
|
||||||
|
t('logs.gps_live_intro_title'),
|
||||||
|
t('logs.gps_live_intro_allow'),
|
||||||
|
t('logs.gps_live_intro_later')
|
||||||
|
)
|
||||||
|
markGeolocationLiveIntroSeen()
|
||||||
|
if (cancelled || !allow) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await getCurrentPosition({
|
||||||
|
timeoutMs: 15_000,
|
||||||
|
enableHighAccuracy: false,
|
||||||
|
maximumAge: 0
|
||||||
|
})
|
||||||
|
if (!cancelled) setGeolocationAccessEpoch((n) => n + 1)
|
||||||
|
} catch (err) {
|
||||||
|
const reason = getGeolocationErrorReason(err)
|
||||||
|
if (reason === 'permission_denied') {
|
||||||
|
await showAlert(
|
||||||
|
`${t('logs.gps_permission_denied')}\n\n${t('logs.gps_enable_in_settings_hint')}`,
|
||||||
|
t('logs.live_title')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [loading, entryId, showAlert, showConfirm, t])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
streamEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
streamEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
}, [events.length])
|
}, [events.length])
|
||||||
@@ -332,7 +499,7 @@ export default function LiveLogView({
|
|||||||
})
|
})
|
||||||
await refreshEntry(entryId)
|
await refreshEntry(entryId)
|
||||||
} catch {
|
} catch {
|
||||||
// Best-effort; hint banner shows when no position fix exists yet.
|
// Best-effort; hint banner shows when no position has been logged yet.
|
||||||
} finally {
|
} finally {
|
||||||
autoPositionBusyRef.current = false
|
autoPositionBusyRef.current = false
|
||||||
}
|
}
|
||||||
@@ -351,7 +518,7 @@ export default function LiveLogView({
|
|||||||
if (startTimer !== undefined) window.clearTimeout(startTimer)
|
if (startTimer !== undefined) window.clearTimeout(startTimer)
|
||||||
if (intervalRef !== undefined) window.clearInterval(intervalRef)
|
if (intervalRef !== undefined) window.clearInterval(intervalRef)
|
||||||
}
|
}
|
||||||
}, [entryId, loading, logbookId, refreshEntry])
|
}, [entryId, loading, logbookId, refreshEntry, geolocationAccessEpoch])
|
||||||
|
|
||||||
const runQuickAction = async (
|
const runQuickAction = async (
|
||||||
action: () => Promise<boolean | void>,
|
action: () => Promise<boolean | void>,
|
||||||
@@ -427,16 +594,26 @@ export default function LiveLogView({
|
|||||||
}, 'moor')
|
}, 'moor')
|
||||||
}
|
}
|
||||||
|
|
||||||
const openFixModal = async () => {
|
const reportPositionGpsFailure = async (reason: GeolocationErrorReason) => {
|
||||||
setFixLat('')
|
setPositionGpsUnavailable(true)
|
||||||
setFixLng('')
|
setPositionGpsErrorReason(reason)
|
||||||
setFixGpsUnavailable(false)
|
setPositionGpsSignal(null)
|
||||||
setFixGpsLoading(true)
|
await showAlert(gpsFailureAlertBody(t, reason), t('logs.live_position'))
|
||||||
setModal('fix')
|
}
|
||||||
|
|
||||||
|
const openPositionModal = async () => {
|
||||||
|
setPositionLat('')
|
||||||
|
setPositionLng('')
|
||||||
|
setPositionGpsUnavailable(false)
|
||||||
|
setPositionGpsErrorReason(null)
|
||||||
|
setPositionGpsSignal(null)
|
||||||
|
setPositionGpsLoading(true)
|
||||||
|
setModal('position')
|
||||||
try {
|
try {
|
||||||
const permission = await queryGeolocationPermission()
|
const permission = await queryGeolocationPermission()
|
||||||
if (permission !== 'granted') {
|
if (permission !== 'granted') {
|
||||||
setFixGpsUnavailable(true)
|
const reason = permission === 'denied' ? 'permission_denied' : 'unavailable'
|
||||||
|
await reportPositionGpsFailure(reason)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const coords = await getCurrentPosition({
|
const coords = await getCurrentPosition({
|
||||||
@@ -444,26 +621,26 @@ export default function LiveLogView({
|
|||||||
enableHighAccuracy: false,
|
enableHighAccuracy: false,
|
||||||
maximumAge: 60_000
|
maximumAge: 60_000
|
||||||
})
|
})
|
||||||
setFixLat(coords.lat)
|
setPositionLat(coords.lat)
|
||||||
setFixLng(coords.lng)
|
setPositionLng(coords.lng)
|
||||||
} catch {
|
setPositionGpsSignal({ quality: coords.signalQuality, accuracyM: coords.accuracyM })
|
||||||
setFixGpsUnavailable(true)
|
} catch (err) {
|
||||||
|
await reportPositionGpsFailure(getGeolocationErrorReason(err))
|
||||||
} finally {
|
} finally {
|
||||||
setFixGpsLoading(false)
|
setPositionGpsLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const retryFixGps = async () => {
|
const retryPositionGps = async () => {
|
||||||
setFixGpsLoading(true)
|
setPositionGpsLoading(true)
|
||||||
setFixGpsUnavailable(false)
|
setPositionGpsUnavailable(false)
|
||||||
|
setPositionGpsErrorReason(null)
|
||||||
|
setPositionGpsSignal(null)
|
||||||
try {
|
try {
|
||||||
const permission = await queryGeolocationPermission()
|
const permission = await queryGeolocationPermission()
|
||||||
if (permission !== 'granted') {
|
if (permission !== 'granted') {
|
||||||
setFixGpsUnavailable(true)
|
const reason = permission === 'denied' ? 'permission_denied' : 'unavailable'
|
||||||
await showAlert(
|
await reportPositionGpsFailure(reason)
|
||||||
`${t('logs.live_gps_error')}\n\n${t('logs.live_gps_start_hint')}`,
|
|
||||||
t('logs.live_fix')
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const coords = await getCurrentPosition({
|
const coords = await getCurrentPosition({
|
||||||
@@ -471,23 +648,21 @@ export default function LiveLogView({
|
|||||||
enableHighAccuracy: false,
|
enableHighAccuracy: false,
|
||||||
maximumAge: 60_000
|
maximumAge: 60_000
|
||||||
})
|
})
|
||||||
setFixLat(coords.lat)
|
setPositionLat(coords.lat)
|
||||||
setFixLng(coords.lng)
|
setPositionLng(coords.lng)
|
||||||
} catch {
|
setPositionGpsUnavailable(false)
|
||||||
setFixGpsUnavailable(true)
|
setPositionGpsSignal({ quality: coords.signalQuality, accuracyM: coords.accuracyM })
|
||||||
await showAlert(
|
} catch (err) {
|
||||||
`${t('logs.live_gps_error')}\n\n${t('logs.live_gps_start_hint')}`,
|
await reportPositionGpsFailure(getGeolocationErrorReason(err))
|
||||||
t('logs.live_fix')
|
|
||||||
)
|
|
||||||
} finally {
|
} finally {
|
||||||
setFixGpsLoading(false)
|
setPositionGpsLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const confirmFix = () => {
|
const confirmPosition = () => {
|
||||||
const coords = normalizeGpsCoordinates(fixLat, fixLng)
|
const coords = normalizeGpsCoordinates(positionLat, positionLng)
|
||||||
if (!coords) {
|
if (!coords) {
|
||||||
void showAlert(t('logs.live_fix_invalid'), t('logs.live_fix'))
|
void showAlert(t('logs.live_position_invalid'), t('logs.live_position'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setModal('none')
|
setModal('none')
|
||||||
@@ -496,25 +671,29 @@ export default function LiveLogView({
|
|||||||
await appendQuickEvent(logbookId, entryId, {
|
await appendQuickEvent(logbookId, entryId, {
|
||||||
gpsLat: coords.lat,
|
gpsLat: coords.lat,
|
||||||
gpsLng: coords.lng,
|
gpsLng: coords.lng,
|
||||||
remarks: LIVE_EVENT_CODES.FIX
|
remarks: LIVE_EVENT_CODES.POSITION
|
||||||
})
|
})
|
||||||
}, 'fix')
|
}, 'position')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFetchOwmWeather = () => {
|
const handleFetchOwmWeather = () => {
|
||||||
if (!entryId || busy || weatherOwmLoading) return
|
if (!entryId || busy || weatherOwmLoading) return
|
||||||
|
if (!isOnline) {
|
||||||
|
void showAlert(t('logs.weather_offline'), t('logs.live_weather_owm_btn'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const position = getLastPositionFixWithin(
|
const position = getLastLoggedPositionWithin(
|
||||||
events,
|
events,
|
||||||
date,
|
date,
|
||||||
LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS
|
LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS
|
||||||
)
|
)
|
||||||
if (!position) {
|
if (!position) {
|
||||||
const latest = getLatestPositionFix(events, date)
|
const latest = getLatestLoggedPosition(events, date)
|
||||||
void showAlert(
|
void showAlert(
|
||||||
latest
|
latest
|
||||||
? t('logs.live_weather_fix_stale')
|
? t('logs.live_weather_position_stale')
|
||||||
: t('logs.live_weather_fix_required'),
|
: t('logs.live_weather_position_required'),
|
||||||
t('logs.live_weather_owm_btn')
|
t('logs.live_weather_owm_btn')
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -533,9 +712,27 @@ export default function LiveLogView({
|
|||||||
{ analyticsSource: 'live_log' }
|
{ analyticsSource: 'live_log' }
|
||||||
)
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof WeatherApiError && err.code === 'NO_KEY') {
|
if (err instanceof WeatherApiError) {
|
||||||
void showAlert(t('settings.no_key'), t('logs.live_weather_owm_btn'))
|
if (err.code === 'OFFLINE') {
|
||||||
return
|
void showAlert(t('logs.weather_offline'), t('logs.live_weather_owm_btn'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (err.code === 'NO_KEY') {
|
||||||
|
void showAlert(t('settings.no_key'), t('logs.live_weather_owm_btn'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (err.code === 'UNAUTHORIZED') {
|
||||||
|
void showAlert(t('settings.weather_unauthorized'), t('logs.live_weather_owm_btn'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (err.code === 'NOT_FOUND') {
|
||||||
|
void showAlert(t('settings.weather_not_found'), t('logs.live_weather_owm_btn'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (err.code === 'BAD_REQUEST') {
|
||||||
|
void showAlert(t('settings.weather_bad_request'), t('logs.live_weather_owm_btn'))
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
console.error('Live log OWM weather failed:', err)
|
console.error('Live log OWM weather failed:', err)
|
||||||
void showAlert(t('settings.weather_error'), t('logs.live_weather_owm_btn'))
|
void showAlert(t('settings.weather_error'), t('logs.live_weather_owm_btn'))
|
||||||
@@ -590,8 +787,10 @@ export default function LiveLogView({
|
|||||||
const handleUndo = () => {
|
const handleUndo = () => {
|
||||||
if (!entryId || busy) return
|
if (!entryId || busy) return
|
||||||
const photoId = undoPhotoIdRef.current
|
const photoId = undoPhotoIdRef.current
|
||||||
|
const voiceId = undoVoiceIdRef.current
|
||||||
setUndoVisible(false)
|
setUndoVisible(false)
|
||||||
undoPhotoIdRef.current = null
|
undoPhotoIdRef.current = null
|
||||||
|
undoVoiceIdRef.current = null
|
||||||
if (undoTimerRef.current) {
|
if (undoTimerRef.current) {
|
||||||
window.clearTimeout(undoTimerRef.current)
|
window.clearTimeout(undoTimerRef.current)
|
||||||
undoTimerRef.current = null
|
undoTimerRef.current = null
|
||||||
@@ -600,6 +799,9 @@ export default function LiveLogView({
|
|||||||
if (photoId) {
|
if (photoId) {
|
||||||
await deleteEntryPhoto(logbookId, photoId)
|
await deleteEntryPhoto(logbookId, photoId)
|
||||||
}
|
}
|
||||||
|
if (voiceId) {
|
||||||
|
await deleteEntryVoiceMemo(logbookId, voiceId)
|
||||||
|
}
|
||||||
await removeLastEvent(logbookId, entryId)
|
await removeLastEvent(logbookId, entryId)
|
||||||
}, 'undo', false)
|
}, 'undo', false)
|
||||||
}
|
}
|
||||||
@@ -615,6 +817,110 @@ export default function LiveLogView({
|
|||||||
setPhotoCaption('')
|
setPhotoCaption('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openVoiceModal = () => {
|
||||||
|
setVoiceCaption('')
|
||||||
|
setModal('voice')
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeVoiceModal = () => {
|
||||||
|
if (voiceSaving) return
|
||||||
|
setModal('none')
|
||||||
|
setVoiceCaption('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleVoiceSave = (blob: Blob, mimeType: string, durationSec: number) => {
|
||||||
|
if (!entryId || voiceSaving) return
|
||||||
|
const caption = voiceCaption.trim()
|
||||||
|
setVoiceSaving(true)
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const audioDataUrl = await blobToAudioDataUrl(blob)
|
||||||
|
const authorized = getAiAuthorized()
|
||||||
|
let transcriptionText = ''
|
||||||
|
let transcribed = true
|
||||||
|
let transcriptionError = false
|
||||||
|
|
||||||
|
if (authorized) {
|
||||||
|
try {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 4000)
|
||||||
|
|
||||||
|
const res = await fetch('/api/ai/transcribe', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ audioDataUrl }),
|
||||||
|
signal: controller.signal
|
||||||
|
})
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
if (!res.ok) throw new Error(`Status ${res.status}`)
|
||||||
|
const data = await res.json()
|
||||||
|
transcriptionText = (data.text || '').trim()
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[LiveLogView] Automatic transcription failed or timed out:', err)
|
||||||
|
transcriptionError = true
|
||||||
|
transcribed = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
transcribed = false
|
||||||
|
}
|
||||||
|
|
||||||
|
let finalCaption = caption
|
||||||
|
if (transcriptionText) {
|
||||||
|
finalCaption = caption
|
||||||
|
? `${caption}\n(Transkript: ${transcriptionText})`
|
||||||
|
: transcriptionText
|
||||||
|
}
|
||||||
|
|
||||||
|
const voiceId = await saveEntryVoiceMemo({
|
||||||
|
logbookId,
|
||||||
|
entryId,
|
||||||
|
audioDataUrl,
|
||||||
|
mimeType,
|
||||||
|
durationSec,
|
||||||
|
caption: finalCaption,
|
||||||
|
transcribed,
|
||||||
|
analyticsContext: 'live_log'
|
||||||
|
})
|
||||||
|
await appendQuickEvent(logbookId, entryId, {
|
||||||
|
remarks: liveVoiceRemark(voiceId)
|
||||||
|
})
|
||||||
|
await refreshEntry(entryId)
|
||||||
|
undoVoiceIdRef.current = voiceId
|
||||||
|
setModal('none')
|
||||||
|
setVoiceCaption('')
|
||||||
|
showUndo('voice')
|
||||||
|
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'voice' })
|
||||||
|
if (transcriptionError) {
|
||||||
|
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, {
|
||||||
|
status: 'failed',
|
||||||
|
mode: 'auto'
|
||||||
|
})
|
||||||
|
void showAlert(t('logs.live_voice_transcribe_failed'), t('logs.live_voice_btn'))
|
||||||
|
} else if (authorized) {
|
||||||
|
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, {
|
||||||
|
status: 'success',
|
||||||
|
mode: 'auto'
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
void showAlert(
|
||||||
|
t('profile.ai_unauthorized_alert_desc'),
|
||||||
|
t('profile.ai_unauthorized_alert_title')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error('Live log voice save failed:', err)
|
||||||
|
const msg = err instanceof Error && err.message === 'VOICE_MEMO_TOO_LARGE'
|
||||||
|
? t('logs.live_voice_too_large')
|
||||||
|
: err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: t('logs.live_voice_error')
|
||||||
|
void showAlert(msg, t('logs.live_voice_btn'))
|
||||||
|
} finally {
|
||||||
|
setVoiceSaving(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
const handlePhotoCapture = (blob: Blob) => {
|
const handlePhotoCapture = (blob: Blob) => {
|
||||||
if (!entryId || photoSaving) return
|
if (!entryId || photoSaving) return
|
||||||
const caption = photoCaption.trim()
|
const caption = photoCaption.trim()
|
||||||
@@ -754,45 +1060,45 @@ export default function LiveLogView({
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'fuel': {
|
case 'fuel': {
|
||||||
const liters = parseFloat(primary)
|
const liters = parseAppDecimal(primary)
|
||||||
if (!Number.isFinite(liters) || liters <= 0) return
|
if (liters == null || liters <= 0) return
|
||||||
setModal('none')
|
setModal('none')
|
||||||
void runQuickAction(async () => {
|
void runQuickAction(async () => {
|
||||||
await appendTankRefill(logbookId, entryId, 'fuel', liters, {
|
await appendTankRefill(logbookId, entryId, 'fuel', liters, {
|
||||||
remarks: liveFuelRemark(String(liters))
|
remarks: liveFuelRemark(formatTankLiters(liters))
|
||||||
})
|
})
|
||||||
}, 'fuel')
|
}, 'fuel')
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'water': {
|
case 'water': {
|
||||||
const liters = parseFloat(primary)
|
const liters = parseAppDecimal(primary)
|
||||||
if (!Number.isFinite(liters) || liters <= 0) return
|
if (liters == null || liters <= 0) return
|
||||||
setModal('none')
|
setModal('none')
|
||||||
void runQuickAction(async () => {
|
void runQuickAction(async () => {
|
||||||
await appendTankRefill(logbookId, entryId, 'freshwater', liters, {
|
await appendTankRefill(logbookId, entryId, 'freshwater', liters, {
|
||||||
remarks: liveWaterRemark(String(liters))
|
remarks: liveWaterRemark(formatTankLiters(liters))
|
||||||
})
|
})
|
||||||
}, 'water')
|
}, 'water')
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'sog': {
|
case 'sog': {
|
||||||
const speedKn = parseFloat(primary.replace(',', '.'))
|
const speedKn = parseAppDecimal(primary)
|
||||||
if (!Number.isFinite(speedKn) || speedKn < 0) return
|
if (speedKn == null || speedKn < 0) return
|
||||||
setModal('none')
|
setModal('none')
|
||||||
void runQuickAction(async () => {
|
void runQuickAction(async () => {
|
||||||
await appendQuickEvent(logbookId, entryId, {
|
await appendQuickEvent(logbookId, entryId, {
|
||||||
remarks: liveSogRemark(String(speedKn))
|
remarks: liveSogRemark(formatSpeedKn(speedKn))
|
||||||
})
|
})
|
||||||
}, 'sog')
|
}, 'sog')
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'stw': {
|
case 'stw': {
|
||||||
const speedKn = parseFloat(primary.replace(',', '.'))
|
const speedKn = parseAppDecimal(primary)
|
||||||
if (!Number.isFinite(speedKn) || speedKn < 0) return
|
if (speedKn == null || speedKn < 0) return
|
||||||
setModal('none')
|
setModal('none')
|
||||||
void runQuickAction(async () => {
|
void runQuickAction(async () => {
|
||||||
await appendQuickEvent(logbookId, entryId, {
|
await appendQuickEvent(logbookId, entryId, {
|
||||||
remarks: liveStwRemark(String(speedKn))
|
remarks: liveStwRemark(formatSpeedKn(speedKn))
|
||||||
})
|
})
|
||||||
}, 'stw')
|
}, 'stw')
|
||||||
break
|
break
|
||||||
@@ -856,7 +1162,7 @@ export default function LiveLogView({
|
|||||||
|
|
||||||
{error && <div className="auth-error mb-4">{error}</div>}
|
{error && <div className="auth-error mb-4">{error}</div>}
|
||||||
|
|
||||||
{!hasPositionFix && (
|
{!hasLoggedPosition && (
|
||||||
<p className="live-log-gps-hint" role="status">
|
<p className="live-log-gps-hint" role="status">
|
||||||
<MapPin size={16} aria-hidden />
|
<MapPin size={16} aria-hidden />
|
||||||
{t('logs.live_gps_start_hint')}
|
{t('logs.live_gps_start_hint')}
|
||||||
@@ -947,9 +1253,9 @@ export default function LiveLogView({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="button" className="live-log-action-btn" onClick={() => void openFixModal()} disabled={busy}>
|
<button type="button" className="live-log-action-btn" onClick={() => void openPositionModal()} disabled={busy}>
|
||||||
<MapPin size={18} />
|
<MapPin size={18} />
|
||||||
{t('logs.live_fix')}
|
{t('logs.live_position')}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="live-log-action-btn" onClick={() => { setCommentText(''); setModal('comment') }} disabled={busy}>
|
<button type="button" className="live-log-action-btn" onClick={() => { setCommentText(''); setModal('comment') }} disabled={busy}>
|
||||||
<MessageSquare size={18} />
|
<MessageSquare size={18} />
|
||||||
@@ -959,6 +1265,10 @@ export default function LiveLogView({
|
|||||||
<Camera size={18} />
|
<Camera size={18} />
|
||||||
{t('logs.live_photo_btn')}
|
{t('logs.live_photo_btn')}
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" className="live-log-action-btn" onClick={openVoiceModal} disabled={busy || voiceSaving}>
|
||||||
|
<Mic size={18} />
|
||||||
|
{t('logs.live_voice_btn')}
|
||||||
|
</button>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section className="live-log-stream-panel" aria-label={t('logs.live_stream_label')}>
|
<section className="live-log-stream-panel" aria-label={t('logs.live_stream_label')}>
|
||||||
@@ -967,12 +1277,26 @@ export default function LiveLogView({
|
|||||||
<p className="live-log-empty">{t('logs.live_no_events')}</p>
|
<p className="live-log-empty">{t('logs.live_no_events')}</p>
|
||||||
) : (
|
) : (
|
||||||
<ol className="live-log-stream">
|
<ol className="live-log-stream">
|
||||||
{events.map((event, index) => (
|
{events.map((event, index) => {
|
||||||
<li key={`${event.time}-${index}`} className="live-log-entry">
|
return (
|
||||||
<time className="live-log-time">{event.time}</time>
|
<li key={`${event.time}-${index}`} className="live-log-entry">
|
||||||
<span className="live-log-summary">{formatEventSummary(event, t)}</span>
|
<time className="live-log-time">{event.time}</time>
|
||||||
</li>
|
<CreatorAvatar
|
||||||
))}
|
creatorId={event.creatorId}
|
||||||
|
crewSnapshotsById={crewSnapshotsById}
|
||||||
|
size={24}
|
||||||
|
/>
|
||||||
|
<div className="live-log-summary-block">
|
||||||
|
<EventRemarksCell
|
||||||
|
event={event}
|
||||||
|
logbookId={logbookId}
|
||||||
|
voiceMemoLookup={voiceMemoLookup}
|
||||||
|
readOnly={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
<div ref={streamEndRef} />
|
<div ref={streamEndRef} />
|
||||||
</ol>
|
</ol>
|
||||||
)}
|
)}
|
||||||
@@ -986,7 +1310,11 @@ export default function LiveLogView({
|
|||||||
<div className="live-log-undo-bar" role="status">
|
<div className="live-log-undo-bar" role="status">
|
||||||
<div className="live-log-undo-bar-inner">
|
<div className="live-log-undo-bar-inner">
|
||||||
<span>
|
<span>
|
||||||
{undoHint === 'photo' ? t('logs.live_undo_photo_hint') : t('logs.live_undo_hint')}
|
{undoHint === 'photo'
|
||||||
|
? t('logs.live_undo_photo_hint')
|
||||||
|
: undoHint === 'voice'
|
||||||
|
? t('logs.live_undo_voice_hint')
|
||||||
|
: t('logs.live_undo_hint')}
|
||||||
</span>
|
</span>
|
||||||
<button type="button" className="btn secondary" onClick={handleUndo} disabled={busy}>
|
<button type="button" className="btn secondary" onClick={handleUndo} disabled={busy}>
|
||||||
<Undo2 size={16} />
|
<Undo2 size={16} />
|
||||||
@@ -1030,7 +1358,7 @@ export default function LiveLogView({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="live-log-modal-actions">
|
<div className="live-log-modal-actions">
|
||||||
<button type="button" className="btn secondary" onClick={closeModal}>{t('logs.confirm_no')}</button>
|
<button type="button" className="btn secondary" onClick={closeModal}>{t('logs.live_cancel')}</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn primary"
|
className="btn primary"
|
||||||
@@ -1046,68 +1374,79 @@ export default function LiveLogView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{modal === 'fix' && (
|
{modal === 'position' && (
|
||||||
<div
|
<div
|
||||||
className="live-log-modal-backdrop"
|
className="live-log-modal-backdrop"
|
||||||
onClick={(e) => { if (e.target === e.currentTarget) closeModal() }}
|
onClick={(e) => { if (e.target === e.currentTarget) closeModal() }}
|
||||||
>
|
>
|
||||||
<div className="live-log-modal" onClick={(e) => e.stopPropagation()}>
|
<div className="live-log-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
<h3>{t('logs.live_fix')}</h3>
|
<h3>{t('logs.live_position')}</h3>
|
||||||
{fixGpsUnavailable && (
|
{positionGpsUnavailable && (
|
||||||
<>
|
<>
|
||||||
<p className="live-log-modal-hint live-log-gps-hint-modal">{t('logs.live_gps_start_hint')}</p>
|
{positionGpsErrorReason && (
|
||||||
<p className="live-log-modal-hint">{t('logs.live_fix_manual_hint')}</p>
|
<p className="live-log-modal-hint live-log-gps-error-modal" role="alert">
|
||||||
|
{t(geolocationErrorI18nKey(positionGpsErrorReason))}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="live-log-modal-hint">{t('logs.live_position_manual_hint')}</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<fieldset className="live-log-fix-coords" disabled={busy}>
|
<fieldset className="live-log-position-coords" disabled={busy}>
|
||||||
<legend className="live-log-fix-label">{t('logs.event_gps')}</legend>
|
<legend className="live-log-position-label">{t('logs.event_gps')}</legend>
|
||||||
<div className="live-log-fix-coords-row">
|
<div className="live-log-position-coords-row">
|
||||||
<label className="live-log-fix-field">
|
<label className="live-log-position-field">
|
||||||
<span className="live-log-fix-field-label">{t('logs.live_fix_lat_placeholder')}</span>
|
<span className="live-log-position-field-label">{t('logs.live_position_lat_placeholder')}</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
className="input-text"
|
className="input-text"
|
||||||
placeholder="54.123456"
|
placeholder="54.123456"
|
||||||
value={fixLat}
|
value={positionLat}
|
||||||
onChange={(e) => setFixLat(e.target.value)}
|
onChange={(e) => { setPositionGpsSignal(null); setPositionLat(e.target.value) }}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="live-log-fix-field">
|
<label className="live-log-position-field">
|
||||||
<span className="live-log-fix-field-label">{t('logs.live_fix_lng_placeholder')}</span>
|
<span className="live-log-position-field-label">{t('logs.live_position_lng_placeholder')}</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
className="input-text"
|
className="input-text"
|
||||||
placeholder="10.654321"
|
placeholder="10.654321"
|
||||||
value={fixLng}
|
value={positionLng}
|
||||||
onChange={(e) => setFixLng(e.target.value)}
|
onChange={(e) => { setPositionGpsSignal(null); setPositionLng(e.target.value) }}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') confirmFix() }}
|
onKeyDown={(e) => { if (e.key === 'Enter') confirmPosition() }}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="live-log-fix-gps-row">
|
{positionGpsSignal && (
|
||||||
|
<GpsSignalHint
|
||||||
|
quality={positionGpsSignal.quality}
|
||||||
|
accuracyM={positionGpsSignal.accuracyM}
|
||||||
|
className="gps-signal-hint-modal"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="live-log-position-gps-row">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn secondary live-log-fix-gps-btn"
|
className="btn secondary live-log-position-gps-btn"
|
||||||
onClick={() => void retryFixGps()}
|
onClick={() => void retryPositionGps()}
|
||||||
title={t('logs.gps_btn')}
|
title={t('logs.gps_btn')}
|
||||||
disabled={fixGpsLoading}
|
disabled={positionGpsLoading}
|
||||||
aria-label={t('logs.gps_btn')}
|
aria-label={t('logs.gps_btn')}
|
||||||
>
|
>
|
||||||
<MapPin size={16} />
|
<MapPin size={16} />
|
||||||
<span>{fixGpsLoading ? t('logs.live_fix_gps_loading') : t('logs.gps_btn')}</span>
|
<span>{positionGpsLoading ? t('logs.live_position_gps_loading') : t('logs.gps_btn')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<div className="live-log-modal-actions">
|
<div className="live-log-modal-actions">
|
||||||
<button type="button" className="btn secondary" onClick={closeModal}>{t('logs.confirm_no')}</button>
|
<button type="button" className="btn secondary" onClick={closeModal}>{t('logs.live_cancel')}</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn primary"
|
className="btn primary"
|
||||||
onClick={confirmFix}
|
onClick={confirmPosition}
|
||||||
disabled={busy || !normalizeGpsCoordinates(fixLat, fixLng)}
|
disabled={busy || !normalizeGpsCoordinates(positionLat, positionLng)}
|
||||||
>
|
>
|
||||||
{t('logs.live_sails_confirm')}
|
{t('logs.live_sails_confirm')}
|
||||||
</button>
|
</button>
|
||||||
@@ -1122,7 +1461,7 @@ export default function LiveLogView({
|
|||||||
<h3>{t('logs.live_comment_btn')}</h3>
|
<h3>{t('logs.live_comment_btn')}</h3>
|
||||||
<input type="text" className="input-text" value={commentText} onChange={(e) => setCommentText(e.target.value)} placeholder={t('logs.live_comment_placeholder')} autoFocus onKeyDown={(e) => { if (e.key === 'Enter') confirmComment() }} />
|
<input type="text" className="input-text" value={commentText} onChange={(e) => setCommentText(e.target.value)} placeholder={t('logs.live_comment_placeholder')} autoFocus onKeyDown={(e) => { if (e.key === 'Enter') confirmComment() }} />
|
||||||
<div className="live-log-modal-actions">
|
<div className="live-log-modal-actions">
|
||||||
<button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.confirm_no')}</button>
|
<button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.live_cancel')}</button>
|
||||||
<button type="button" className="btn primary" onClick={confirmComment} disabled={!commentText.trim()}>{t('logs.live_comment_confirm')}</button>
|
<button type="button" className="btn primary" onClick={confirmComment} disabled={!commentText.trim()}>{t('logs.live_comment_confirm')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1156,7 +1495,7 @@ export default function LiveLogView({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="live-log-modal-actions">
|
<div className="live-log-modal-actions">
|
||||||
<button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.confirm_no')}</button>
|
<button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.live_cancel')}</button>
|
||||||
<button type="button" className="btn primary" onClick={confirmValueModal}>{t('logs.live_sails_confirm')}</button>
|
<button type="button" className="btn primary" onClick={confirmValueModal}>{t('logs.live_sails_confirm')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1178,7 +1517,7 @@ export default function LiveLogView({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="live-log-modal-actions">
|
<div className="live-log-modal-actions">
|
||||||
<button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.confirm_no')}</button>
|
<button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.live_cancel')}</button>
|
||||||
<button type="button" className="btn primary" onClick={confirmValueModal}>{t('logs.live_sails_confirm')}</button>
|
<button type="button" className="btn primary" onClick={confirmValueModal}>{t('logs.live_sails_confirm')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1223,7 +1562,7 @@ export default function LiveLogView({
|
|||||||
onKeyDown={(e) => { if (e.key === 'Enter') confirmValueModal() }}
|
onKeyDown={(e) => { if (e.key === 'Enter') confirmValueModal() }}
|
||||||
/>
|
/>
|
||||||
<div className="live-log-modal-actions">
|
<div className="live-log-modal-actions">
|
||||||
<button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.confirm_no')}</button>
|
<button type="button" className="btn secondary" onClick={() => setModal('none')}>{t('logs.live_cancel')}</button>
|
||||||
<button type="button" className="btn primary" onClick={confirmValueModal}>{t('logs.live_sails_confirm')}</button>
|
<button type="button" className="btn primary" onClick={confirmValueModal}>{t('logs.live_sails_confirm')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1238,6 +1577,15 @@ export default function LiveLogView({
|
|||||||
onClose={closePhotoModal}
|
onClose={closePhotoModal}
|
||||||
onCapture={handlePhotoCapture}
|
onCapture={handlePhotoCapture}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<LiveVoiceCapture
|
||||||
|
open={modal === 'voice'}
|
||||||
|
busy={voiceSaving}
|
||||||
|
caption={voiceCaption}
|
||||||
|
onCaptionChange={setVoiceCaption}
|
||||||
|
onClose={closeVoiceModal}
|
||||||
|
onSave={handleVoiceSave}
|
||||||
|
/>
|
||||||
</>,
|
</>,
|
||||||
document.body
|
document.body
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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,13 +3,14 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { db } from '../services/db.js'
|
import { db } from '../services/db.js'
|
||||||
import { getActiveMasterKey } from '../services/auth.js'
|
import { getActiveMasterKey } from '../services/auth.js'
|
||||||
import { getLogbookKey } from '../services/logbookKeys.js'
|
import { getLogbookKey } from '../services/logbookKeys.js'
|
||||||
import { decryptJson, encryptJson } from '../services/crypto.js'
|
import { encryptJson } from '../services/crypto.js'
|
||||||
import { syncLogbook } from '../services/sync.js'
|
import { syncLogbook } from '../services/sync.js'
|
||||||
import { downloadCsv, shareCsv } from '../services/csvExport.js'
|
import { downloadCsv, shareCsv } from '../services/csvExport.js'
|
||||||
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
import { getErrorMessage } from '../utils/errors.js'
|
import { 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 LogEntryEditor from './LogEntryEditor.tsx'
|
||||||
import LiveLogView from './LiveLogView.tsx'
|
import LiveLogView from './LiveLogView.tsx'
|
||||||
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
|
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
|
||||||
@@ -39,6 +40,7 @@ interface LogEntriesListProps {
|
|||||||
preloadedYacht?: any
|
preloadedYacht?: any
|
||||||
preloadedEntries?: any[]
|
preloadedEntries?: any[]
|
||||||
preloadedPhotos?: any[]
|
preloadedPhotos?: any[]
|
||||||
|
preloadedVoiceMemos?: import('./VoiceMemoPlayer.tsx').PreloadedVoiceMemo[]
|
||||||
preloadedGpsTracks?: any[]
|
preloadedGpsTracks?: any[]
|
||||||
controlledSelectedEntryId?: string | null
|
controlledSelectedEntryId?: string | null
|
||||||
onSelectedEntryIdChange?: (id: string | null) => void
|
onSelectedEntryIdChange?: (id: string | null) => void
|
||||||
@@ -63,6 +65,7 @@ export default function LogEntriesList({
|
|||||||
preloadedYacht,
|
preloadedYacht,
|
||||||
preloadedEntries,
|
preloadedEntries,
|
||||||
preloadedPhotos,
|
preloadedPhotos,
|
||||||
|
preloadedVoiceMemos,
|
||||||
preloadedGpsTracks,
|
preloadedGpsTracks,
|
||||||
controlledSelectedEntryId,
|
controlledSelectedEntryId,
|
||||||
onSelectedEntryIdChange,
|
onSelectedEntryIdChange,
|
||||||
@@ -121,6 +124,11 @@ export default function LogEntriesList({
|
|||||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||||
|
|
||||||
|
const todayEntryId = await findTodayEntryId(logbookId)
|
||||||
|
if (todayEntryId) {
|
||||||
|
await pruneEmptyTodayDuplicates(logbookId, todayEntryId)
|
||||||
|
}
|
||||||
|
|
||||||
const local = await db.entries.where({ logbookId }).toArray()
|
const local = await db.entries.where({ logbookId }).toArray()
|
||||||
|
|
||||||
const list: DecryptedEntryItem[] = []
|
const list: DecryptedEntryItem[] = []
|
||||||
@@ -136,7 +144,7 @@ export default function LogEntriesList({
|
|||||||
}
|
}
|
||||||
|
|
||||||
await forEachInBatches(needsDecrypt, 8, async (entry) => {
|
await forEachInBatches(needsDecrypt, 8, async (entry) => {
|
||||||
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
|
const decrypted = await tryDecryptEntryPayload(entry, masterKey)
|
||||||
if (!decrypted) return
|
if (!decrypted) return
|
||||||
|
|
||||||
const listCache = await buildEntryListCache(decrypted as Record<string, unknown>)
|
const listCache = await buildEntryListCache(decrypted as Record<string, unknown>)
|
||||||
@@ -266,7 +274,7 @@ export default function LogEntriesList({
|
|||||||
const decryptedEntries: Array<LogEntryTankSource & TravelDaySortable> = []
|
const decryptedEntries: Array<LogEntryTankSource & TravelDaySortable> = []
|
||||||
|
|
||||||
for (const entry of localEntries) {
|
for (const entry of localEntries) {
|
||||||
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
|
const decrypted = await tryDecryptEntryPayload(entry, masterKey)
|
||||||
if (decrypted) decryptedEntries.push(decrypted as LogEntryTankSource & TravelDaySortable)
|
if (decrypted) decryptedEntries.push(decrypted as LogEntryTankSource & TravelDaySortable)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,7 +306,7 @@ export default function LogEntriesList({
|
|||||||
|
|
||||||
const localId = window.crypto.randomUUID()
|
const localId = window.crypto.randomUUID()
|
||||||
const nowStr = new Date().toISOString()
|
const nowStr = new Date().toISOString()
|
||||||
const todayStr = nowStr.substring(0, 10)
|
const todayStr = localDateString()
|
||||||
|
|
||||||
const { loadDefaultEntryCrewForNewDay } = await import('./EntryCrewSection.js')
|
const { loadDefaultEntryCrewForNewDay } = await import('./EntryCrewSection.js')
|
||||||
const entryCrew = await loadDefaultEntryCrewForNewDay(
|
const entryCrew = await loadDefaultEntryCrewForNewDay(
|
||||||
@@ -403,6 +411,7 @@ export default function LogEntriesList({
|
|||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
preloadedEntry={preloadedEntries?.find(entry => (entry.payloadId || entry.id) === selectedEntryId)}
|
preloadedEntry={preloadedEntries?.find(entry => (entry.payloadId || entry.id) === selectedEntryId)}
|
||||||
preloadedPhotos={preloadedPhotos}
|
preloadedPhotos={preloadedPhotos}
|
||||||
|
preloadedVoiceMemos={preloadedVoiceMemos}
|
||||||
preloadedTrack={preloadedGpsTracks?.find(track => track.entryId === selectedEntryId)}
|
preloadedTrack={preloadedGpsTracks?.find(track => track.entryId === selectedEntryId)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,10 +5,12 @@ import { useDialog } from './ModalDialog.tsx'
|
|||||||
import {
|
import {
|
||||||
downloadBackupBlob,
|
downloadBackupBlob,
|
||||||
exportLogbookBackup,
|
exportLogbookBackup,
|
||||||
|
formatBackupBytes,
|
||||||
parseLogbookBackupFile,
|
parseLogbookBackupFile,
|
||||||
previewLogbookBackup,
|
previewLogbookBackup,
|
||||||
restoreLogbookBackup,
|
restoreLogbookBackup,
|
||||||
type LogbookBackupFile,
|
BACKUP_SIZE_CONFIRM_BYTES,
|
||||||
|
type ParsedLogbookBackup,
|
||||||
type LogbookBackupPreview
|
type LogbookBackupPreview
|
||||||
} from '../services/logbookBackup.js'
|
} from '../services/logbookBackup.js'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
@@ -27,6 +29,12 @@ function mapBackupError(code: string, t: (key: string) => string): string {
|
|||||||
return t('settings.backup_not_owner')
|
return t('settings.backup_not_owner')
|
||||||
case 'BACKUP_INVALID_JSON':
|
case 'BACKUP_INVALID_JSON':
|
||||||
return t('settings.backup_invalid_json')
|
return t('settings.backup_invalid_json')
|
||||||
|
case 'BACKUP_INVALID_ARCHIVE':
|
||||||
|
return t('settings.backup_invalid_archive')
|
||||||
|
case 'BACKUP_VERSION_UNSUPPORTED':
|
||||||
|
return t('settings.backup_version_unsupported')
|
||||||
|
case 'BACKUP_WRONG_PASSPHRASE':
|
||||||
|
return t('settings.backup_wrong_passphrase')
|
||||||
case 'BACKUP_INVALID_FORMAT':
|
case 'BACKUP_INVALID_FORMAT':
|
||||||
return t('settings.backup_invalid_format')
|
return t('settings.backup_invalid_format')
|
||||||
case 'BACKUP_NOT_AUTHENTICATED':
|
case 'BACKUP_NOT_AUTHENTICATED':
|
||||||
@@ -53,12 +61,16 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
|||||||
const [importPassphrase, setImportPassphrase] = useState('')
|
const [importPassphrase, setImportPassphrase] = useState('')
|
||||||
const [importFile, setImportFile] = useState<File | null>(null)
|
const [importFile, setImportFile] = useState<File | null>(null)
|
||||||
const [importPreview, setImportPreview] = useState<LogbookBackupPreview | null>(null)
|
const [importPreview, setImportPreview] = useState<LogbookBackupPreview | null>(null)
|
||||||
const [parsedBackup, setParsedBackup] = useState<LogbookBackupFile | null>(null)
|
const [parsedBackup, setParsedBackup] = useState<ParsedLogbookBackup | null>(null)
|
||||||
const [importing, setImporting] = useState(false)
|
const [importing, setImporting] = useState(false)
|
||||||
const [previewing, setPreviewing] = useState(false)
|
const [previewing, setPreviewing] = useState(false)
|
||||||
|
const [exportProgress, setExportProgress] = useState<string | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [success, setSuccess] = useState<string | null>(null)
|
const [success, setSuccess] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const exportPassphrasesMatch =
|
||||||
|
exportPassphrase.length >= 8 && exportPassphrase === exportConfirm
|
||||||
|
|
||||||
const handleExportSubmit = async (e: React.FormEvent) => {
|
const handleExportSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
await handleExport()
|
await handleExport()
|
||||||
@@ -83,21 +95,36 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
|||||||
}
|
}
|
||||||
|
|
||||||
setExporting(true)
|
setExporting(true)
|
||||||
|
setExportProgress(null)
|
||||||
try {
|
try {
|
||||||
const { blob, filename, backup } = await exportLogbookBackup(logbookId, exportPassphrase)
|
const { blob, filename, manifest } = await exportLogbookBackup(logbookId, exportPassphrase, {
|
||||||
|
onProgress: (p) => {
|
||||||
|
if (p.phase === 'pack') {
|
||||||
|
setExportProgress(
|
||||||
|
t('settings.backup_export_progress', {
|
||||||
|
current: p.current,
|
||||||
|
total: p.total
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
downloadBackupBlob(blob, filename)
|
downloadBackupBlob(blob, filename)
|
||||||
setSuccess(t('settings.backup_export_success', { count: backup.counts.entries }))
|
setSuccess(t('settings.backup_export_success', { count: manifest.counts.entries }))
|
||||||
setExportPassphrase('')
|
setExportPassphrase('')
|
||||||
setExportConfirm('')
|
setExportConfirm('')
|
||||||
trackPlausibleEvent(PlausibleEvents.BACKUP_EXPORTED, {
|
trackPlausibleEvent(PlausibleEvents.BACKUP_EXPORTED, {
|
||||||
entries: backup.counts.entries,
|
entries: manifest.counts.entries,
|
||||||
photos: backup.counts.photos
|
photos: manifest.counts.photos,
|
||||||
|
voiceMemos: manifest.counts.voiceMemos,
|
||||||
|
bytes: manifest.totalUncompressedBytes
|
||||||
})
|
})
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
setError(mapBackupError(message, t))
|
setError(mapBackupError(message, t))
|
||||||
} finally {
|
} finally {
|
||||||
setExporting(false)
|
setExporting(false)
|
||||||
|
setExportProgress(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,6 +165,18 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
|||||||
const handleRestore = async (options: { overwrite?: boolean; assignNewId?: boolean } = {}) => {
|
const handleRestore = async (options: { overwrite?: boolean; assignNewId?: boolean } = {}) => {
|
||||||
if (!parsedBackup || !importPassphrase) return
|
if (!parsedBackup || !importPassphrase) return
|
||||||
|
|
||||||
|
if (parsedBackup.manifest.totalUncompressedBytes > BACKUP_SIZE_CONFIRM_BYTES) {
|
||||||
|
const ok = await showConfirm(
|
||||||
|
t('settings.backup_import_size_confirm', {
|
||||||
|
size: formatBackupBytes(parsedBackup.manifest.totalUncompressedBytes)
|
||||||
|
}),
|
||||||
|
t('settings.backup_restore_title'),
|
||||||
|
t('logs.confirm_yes'),
|
||||||
|
t('logs.confirm_no')
|
||||||
|
)
|
||||||
|
if (!ok) return
|
||||||
|
}
|
||||||
|
|
||||||
setImporting(true)
|
setImporting(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
@@ -149,8 +188,10 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
|||||||
setParsedBackup(null)
|
setParsedBackup(null)
|
||||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||||
trackPlausibleEvent(PlausibleEvents.BACKUP_RESTORED, {
|
trackPlausibleEvent(PlausibleEvents.BACKUP_RESTORED, {
|
||||||
entries: parsedBackup.counts.entries,
|
entries: parsedBackup.manifest.counts.entries,
|
||||||
photos: parsedBackup.counts.photos,
|
photos: parsedBackup.manifest.counts.photos,
|
||||||
|
voiceMemos: parsedBackup.manifest.counts.voiceMemos,
|
||||||
|
bytes: parsedBackup.manifest.totalUncompressedBytes,
|
||||||
mode: options.overwrite ? 'overwrite' : options.assignNewId ? 'new_id' : 'same_id'
|
mode: options.overwrite ? 'overwrite' : options.assignNewId ? 'new_id' : 'same_id'
|
||||||
})
|
})
|
||||||
onRestored?.(result.logbookId, result.title)
|
onRestored?.(result.logbookId, result.title)
|
||||||
@@ -253,11 +294,16 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn primary"
|
className="btn primary"
|
||||||
disabled={exporting || !exportPassphrase || !exportConfirm}
|
disabled={exporting || !exportPassphrasesMatch}
|
||||||
>
|
>
|
||||||
<Download size={16} />
|
<Download size={16} />
|
||||||
{exporting ? t('settings.backup_exporting') : t('settings.backup_export_btn')}
|
{exporting ? t('settings.backup_exporting') : t('settings.backup_export_btn')}
|
||||||
</button>
|
</button>
|
||||||
|
{exportProgress && (
|
||||||
|
<p className="text-muted backup-export-progress" role="status">
|
||||||
|
{exportProgress}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -275,7 +321,7 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
|||||||
id="backup-import-file"
|
id="backup-import-file"
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept=".daagbok.json,application/json"
|
accept=".daagbok,application/zip"
|
||||||
className="input-text"
|
className="input-text"
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
disabled={importing}
|
disabled={importing}
|
||||||
@@ -330,8 +376,14 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
|||||||
<ul className="backup-preview-stats">
|
<ul className="backup-preview-stats">
|
||||||
<li>{t('settings.backup_stat_entries', { count: importPreview.counts.entries })}</li>
|
<li>{t('settings.backup_stat_entries', { count: importPreview.counts.entries })}</li>
|
||||||
<li>{t('settings.backup_stat_photos', { count: importPreview.counts.photos })}</li>
|
<li>{t('settings.backup_stat_photos', { count: importPreview.counts.photos })}</li>
|
||||||
|
<li>{t('settings.backup_stat_voice', { count: importPreview.counts.voiceMemos })}</li>
|
||||||
<li>{t('settings.backup_stat_crew', { count: importPreview.counts.crews })}</li>
|
<li>{t('settings.backup_stat_crew', { count: importPreview.counts.crews })}</li>
|
||||||
<li>{t('settings.backup_stat_tracks', { count: importPreview.counts.gpsTracks })}</li>
|
<li>{t('settings.backup_stat_tracks', { count: importPreview.counts.gpsTracks })}</li>
|
||||||
|
<li className="text-muted">
|
||||||
|
{t('settings.backup_stat_size', {
|
||||||
|
size: formatBackupBytes(importPreview.totalUncompressedBytes)
|
||||||
|
})}
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-muted backup-preview-date">
|
<p className="text-muted backup-preview-date">
|
||||||
{t('settings.backup_exported_at', {
|
{t('settings.backup_exported_at', {
|
||||||
|
|||||||
@@ -15,11 +15,13 @@ import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, Wifi, WifiO
|
|||||||
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
|
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
|
||||||
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
|
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
|
||||||
import ProfileHeaderButton from './ProfileHeaderButton.tsx'
|
import ProfileHeaderButton from './ProfileHeaderButton.tsx'
|
||||||
|
import AdminHeaderButton from './AdminHeaderButton.tsx'
|
||||||
|
|
||||||
interface LogbookDashboardProps {
|
interface LogbookDashboardProps {
|
||||||
onSelectLogbook: (id: string, title: string) => void
|
onSelectLogbook: (id: string, title: string) => void
|
||||||
onLogout: () => void
|
onLogout: () => void
|
||||||
onOpenProfile: () => void
|
onOpenProfile: () => void
|
||||||
|
onOpenAdmin?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type LogbookSortKey = 'name' | 'date'
|
type LogbookSortKey = 'name' | 'date'
|
||||||
@@ -42,7 +44,7 @@ function sortLogbooks(
|
|||||||
return sorted
|
return sorted
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProfile }: LogbookDashboardProps) {
|
export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProfile, onOpenAdmin }: LogbookDashboardProps) {
|
||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
const { showConfirm } = useDialog()
|
const { showConfirm } = useDialog()
|
||||||
const [logbooks, setLogbooks] = useState<DecryptedLogbook[]>([])
|
const [logbooks, setLogbooks] = useState<DecryptedLogbook[]>([])
|
||||||
@@ -388,6 +390,8 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
|
|
||||||
<ProfileHeaderButton onClick={onOpenProfile} />
|
<ProfileHeaderButton onClick={onOpenProfile} />
|
||||||
|
|
||||||
|
{onOpenAdmin && <AdminHeaderButton onClick={onOpenAdmin} />}
|
||||||
|
|
||||||
{/* Lang toggle */}
|
{/* Lang toggle */}
|
||||||
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
|
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
|
||||||
<Languages size={18} />
|
<Languages size={18} />
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ export default function PersonPoolForm() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn-icon logout"
|
className="btn-icon danger"
|
||||||
onClick={() => void handleDelete(person.payloadId)}
|
onClick={() => void handleDelete(person.payloadId)}
|
||||||
title="Delete"
|
title="Delete"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react'
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { db } from '../services/db.js'
|
import { db } from '../services/db.js'
|
||||||
import { getActiveMasterKey } from '../services/auth.js'
|
import { getActiveMasterKey } from '../services/auth.js'
|
||||||
@@ -8,7 +9,8 @@ import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.j
|
|||||||
import { fileToCompressedJpegDataUrl } from '../utils/imageCompress.js'
|
import { fileToCompressedJpegDataUrl } from '../utils/imageCompress.js'
|
||||||
import { useLiveQuery } from 'dexie-react-hooks'
|
import { useLiveQuery } from 'dexie-react-hooks'
|
||||||
import { useDialog } from './ModalDialog.tsx'
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
import { Camera, Trash2 } from 'lucide-react'
|
import { Camera, Image, Trash2, X, ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
|
import { probeCameraAvailability } from '../utils/cameraAvailability.js'
|
||||||
|
|
||||||
interface PhotoCaptureProps {
|
interface PhotoCaptureProps {
|
||||||
entryId: string
|
entryId: string
|
||||||
@@ -27,12 +29,43 @@ interface DecryptedPhoto {
|
|||||||
export default function PhotoCapture({ entryId, logbookId, readOnly = false, preloadedPhotos }: PhotoCaptureProps) {
|
export default function PhotoCapture({ entryId, logbookId, readOnly = false, preloadedPhotos }: PhotoCaptureProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { showConfirm } = useDialog()
|
const { showConfirm } = useDialog()
|
||||||
|
const [collapsed, setCollapsed] = useState(true)
|
||||||
const [caption, setCaption] = useState('')
|
const [caption, setCaption] = useState('')
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [decryptedPhotos, setDecryptedPhotos] = useState<DecryptedPhoto[]>([])
|
const [decryptedPhotos, setDecryptedPhotos] = useState<DecryptedPhoto[]>([])
|
||||||
|
const [hasCamera, setHasCamera] = useState(false)
|
||||||
|
const [maximizedPhoto, setMaximizedPhoto] = useState<DecryptedPhoto | null>(null)
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const cameraInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!maximizedPhoto) return
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setMaximizedPhoto(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}
|
||||||
|
}, [maximizedPhoto])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
probeCameraAvailability().then((avail) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setHasCamera(avail === 'available')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Reactively query local photos database
|
// Reactively query local photos database
|
||||||
const localPhotos = useLiveQuery(
|
const localPhotos = useLiveQuery(
|
||||||
@@ -119,93 +152,201 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const triggerSelect = () => {
|
const triggerGallerySelect = () => {
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.click()
|
fileInputRef.current.click()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const triggerCameraSelect = () => {
|
||||||
|
if (cameraInputRef.current) {
|
||||||
|
cameraInputRef.current.click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="form-card mt-6">
|
<div className="form-card mt-6">
|
||||||
<div className="form-header mb-4">
|
<div
|
||||||
<Camera size={20} className="form-icon" />
|
className="form-header accordion-header"
|
||||||
<h3>{t('logs.photos_title')}</h3>
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
setCollapsed(!collapsed)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
aria-expanded={!collapsed}
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<div className="accordion-header-title">
|
||||||
|
<Camera size={20} className="form-icon" />
|
||||||
|
<h3>{t('logs.photos_title')}</h3>
|
||||||
|
</div>
|
||||||
|
{collapsed ? (
|
||||||
|
<ChevronDown size={20} className="accordion-chevron" />
|
||||||
|
) : (
|
||||||
|
<ChevronUp size={20} className="accordion-chevron" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="auth-error mb-4">{error}</div>}
|
{!collapsed && (
|
||||||
|
<div style={{ marginTop: '16px' }}>
|
||||||
|
{error && <div className="auth-error mb-4">{error}</div>}
|
||||||
|
|
||||||
{/* Upload area */}
|
{/* Upload area */}
|
||||||
{/* Upload Form */}
|
{/* Upload Form */}
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<div className="member-editor-card glass mb-6" style={{ padding: '16px' }}>
|
<div className="member-editor-card glass mb-6" style={{ padding: '16px' }}>
|
||||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '12px', alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||||
<div className="input-group" style={{ flex: '1', minWidth: '200px', margin: 0 }}>
|
<div className="input-group" style={{ flex: '1', minWidth: '200px', margin: 0 }}>
|
||||||
<label>{t('logs.photo_caption_label')}</label>
|
<label>{t('logs.photo_caption_label')}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={t('logs.photo_caption_placeholder')}
|
placeholder={t('logs.photo_caption_placeholder')}
|
||||||
className="input-text"
|
className="input-text"
|
||||||
value={caption}
|
value={caption}
|
||||||
onChange={(e) => setCaption(e.target.value)}
|
onChange={(e) => setCaption(e.target.value)}
|
||||||
disabled={uploading}
|
disabled={uploading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
<input
|
||||||
type="button"
|
type="file"
|
||||||
className="btn primary"
|
accept="image/*"
|
||||||
onClick={triggerSelect}
|
capture="environment"
|
||||||
disabled={uploading}
|
ref={cameraInputRef}
|
||||||
style={{ width: 'auto', padding: '12px 24px', display: 'flex', gap: '8px', alignItems: 'center' }}
|
onChange={handleFileChange}
|
||||||
>
|
style={{ display: 'none' }}
|
||||||
{uploading ? (
|
/>
|
||||||
<span className="spin">⏳</span>
|
|
||||||
) : (
|
|
||||||
<Camera size={16} />
|
|
||||||
)}
|
|
||||||
{uploading ? t('logs.photo_processing') : t('logs.photo_btn')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Photo Grid */}
|
{hasCamera ? (
|
||||||
{decryptedPhotos.length === 0 ? (
|
<>
|
||||||
<div className="dashboard-status-msg">{t('logs.no_photos')}</div>
|
<button
|
||||||
) : (
|
type="button"
|
||||||
<div className="photo-attachments-grid">
|
className="btn primary"
|
||||||
{decryptedPhotos.map((photo) => (
|
onClick={triggerCameraSelect}
|
||||||
<div key={photo.payloadId} className="photo-card glass">
|
disabled={uploading}
|
||||||
<div className="photo-container">
|
style={{ width: 'auto', padding: '12px 20px', display: 'flex', gap: '8px', alignItems: 'center' }}
|
||||||
<img src={photo.image} alt={photo.caption || 'Attachment'} loading="lazy" />
|
>
|
||||||
{!readOnly && (
|
{uploading ? (
|
||||||
|
<span className="spin">⏳</span>
|
||||||
|
) : (
|
||||||
|
<Camera size={16} />
|
||||||
|
)}
|
||||||
|
{uploading ? t('logs.photo_processing') : t('logs.photo_camera_btn')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={triggerGallerySelect}
|
||||||
|
disabled={uploading}
|
||||||
|
style={{ width: 'auto', padding: '12px 20px', display: 'flex', gap: '8px', alignItems: 'center' }}
|
||||||
|
>
|
||||||
|
{uploading ? (
|
||||||
|
<span className="spin">⏳</span>
|
||||||
|
) : (
|
||||||
|
<Image size={16} />
|
||||||
|
)}
|
||||||
|
{uploading ? t('logs.photo_processing') : t('logs.photo_gallery_btn')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="photo-btn-delete"
|
className="btn primary"
|
||||||
onClick={() => handleDelete(photo.payloadId)}
|
onClick={triggerGallerySelect}
|
||||||
title="Remove photo"
|
disabled={uploading}
|
||||||
|
style={{ width: 'auto', padding: '12px 24px', display: 'flex', gap: '8px', alignItems: 'center' }}
|
||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
{uploading ? (
|
||||||
|
<span className="spin">⏳</span>
|
||||||
|
) : (
|
||||||
|
<Camera size={16} />
|
||||||
|
)}
|
||||||
|
{uploading ? t('logs.photo_processing') : t('logs.photo_btn')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{photo.caption && (
|
|
||||||
<div className="photo-caption-bar">
|
|
||||||
<span>{photo.caption}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
|
|
||||||
|
{/* Photo Grid */}
|
||||||
|
{decryptedPhotos.length === 0 ? (
|
||||||
|
<div className="dashboard-status-msg">{t('logs.no_photos')}</div>
|
||||||
|
) : (
|
||||||
|
<div className="photo-attachments-grid">
|
||||||
|
{decryptedPhotos.map((photo) => (
|
||||||
|
<div
|
||||||
|
key={photo.payloadId}
|
||||||
|
className="photo-card glass"
|
||||||
|
onClick={() => setMaximizedPhoto(photo)}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<div className="photo-container">
|
||||||
|
<img src={photo.image} alt={photo.caption || 'Attachment'} loading="lazy" />
|
||||||
|
{!readOnly && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="photo-btn-delete"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleDelete(photo.payloadId)
|
||||||
|
}}
|
||||||
|
title="Remove photo"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{photo.caption && (
|
||||||
|
<div className="photo-caption-bar">
|
||||||
|
<span>{photo.caption}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{maximizedPhoto && createPortal(
|
||||||
|
<div
|
||||||
|
className="photo-maximized-overlay"
|
||||||
|
onClick={() => setMaximizedPhoto(null)}
|
||||||
|
>
|
||||||
|
<div className="photo-maximized-container" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="photo-maximized-close"
|
||||||
|
onClick={() => setMaximizedPhoto(null)}
|
||||||
|
aria-label={t('common.close') || 'Close'}
|
||||||
|
>
|
||||||
|
<X size={24} />
|
||||||
|
</button>
|
||||||
|
<img
|
||||||
|
src={maximizedPhoto.image}
|
||||||
|
alt={maximizedPhoto.caption || 'Maximized Attachment'}
|
||||||
|
className="photo-maximized-img"
|
||||||
|
/>
|
||||||
|
{maximizedPhoto.caption && (
|
||||||
|
<div className="photo-maximized-caption">
|
||||||
|
{maximizedPhoto.caption}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
|||||||
const [legacyCrews, setLegacyCrews] = useState<any[]>([])
|
const [legacyCrews, setLegacyCrews] = useState<any[]>([])
|
||||||
const [entries, setEntries] = useState<any[]>([])
|
const [entries, setEntries] = useState<any[]>([])
|
||||||
const [photos, setPhotos] = useState<any[]>([])
|
const [photos, setPhotos] = useState<any[]>([])
|
||||||
|
const [voiceMemos, setVoiceMemos] = useState<any[]>([])
|
||||||
const [gpsTracks, setGpsTracks] = useState<any[]>([])
|
const [gpsTracks, setGpsTracks] = useState<any[]>([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -174,6 +175,23 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
|||||||
}
|
}
|
||||||
setPhotos(decPhotos)
|
setPhotos(decPhotos)
|
||||||
|
|
||||||
|
const decVoiceMemos = []
|
||||||
|
if (data.voiceMemos) {
|
||||||
|
for (const v of data.voiceMemos) {
|
||||||
|
const dec = await decryptJson(v.encryptedData, v.iv, v.tag, keyBuffer)
|
||||||
|
if (dec) {
|
||||||
|
decVoiceMemos.push({
|
||||||
|
payloadId: v.payloadId,
|
||||||
|
audio: dec.audio,
|
||||||
|
mimeType: dec.mimeType,
|
||||||
|
durationSec: dec.durationSec,
|
||||||
|
caption: dec.caption || ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setVoiceMemos(decVoiceMemos)
|
||||||
|
|
||||||
// Decrypt GPS Tracks
|
// Decrypt GPS Tracks
|
||||||
const decGpsTracks = []
|
const decGpsTracks = []
|
||||||
if (data.gpsTracks) {
|
if (data.gpsTracks) {
|
||||||
@@ -282,6 +300,7 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
|||||||
preloadedYacht={yacht}
|
preloadedYacht={yacht}
|
||||||
preloadedEntries={entries}
|
preloadedEntries={entries}
|
||||||
preloadedPhotos={photos}
|
preloadedPhotos={photos}
|
||||||
|
preloadedVoiceMemos={voiceMemos}
|
||||||
preloadedGpsTracks={gpsTracks}
|
preloadedGpsTracks={gpsTracks}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -429,7 +429,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
|||||||
<td>
|
<td>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn-icon logout"
|
className="btn-icon danger"
|
||||||
onClick={() => handleRevoke(c.id, c.username)}
|
onClick={() => handleRevoke(c.id, c.username)}
|
||||||
title="Revoke access"
|
title="Revoke access"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from '../services/statsAggregation.js'
|
} from '../services/statsAggregation.js'
|
||||||
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
|
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
|
||||||
import { formatFuelPerMotorHour } from '../utils/fuelStats.js'
|
import { formatFuelPerMotorHour } from '../utils/fuelStats.js'
|
||||||
|
import { formatAppDecimal } from '../utils/numberFormat.js'
|
||||||
import {
|
import {
|
||||||
loadLogbookEventSeries,
|
loadLogbookEventSeries,
|
||||||
type EventSeriesPoint,
|
type EventSeriesPoint,
|
||||||
@@ -211,8 +212,8 @@ function PropulsionBreakdown({ totals }: { totals: StatsTotals }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="stats-propulsion-labels">
|
<div className="stats-propulsion-labels">
|
||||||
<span>{t('stats.sail_distance')}: {formatNm(totals.sailDistanceNm)} {t('stats.unit_nm')} ({sailPct.toFixed(0)}%)</span>
|
<span>{t('stats.sail_distance')}: {formatNm(totals.sailDistanceNm)} {t('stats.unit_nm')} ({formatAppDecimal(sailPct, { maximumFractionDigits: 0 })}%)</span>
|
||||||
<span>{t('stats.motor_distance')}: {formatNm(totals.motorDistanceNm)} {t('stats.unit_nm')} ({motorPct.toFixed(0)}%)</span>
|
<span>{t('stats.motor_distance')}: {formatNm(totals.motorDistanceNm)} {t('stats.unit_nm')} ({formatAppDecimal(motorPct, { maximumFractionDigits: 0 })}%)</span>
|
||||||
{totals.unknownPropulsionNm > 0 && (
|
{totals.unknownPropulsionNm > 0 && (
|
||||||
<span>{t('stats.unknown_propulsion')}: {formatNm(totals.unknownPropulsionNm)} {t('stats.unit_nm')}</span>
|
<span>{t('stats.unknown_propulsion')}: {formatNm(totals.unknownPropulsionNm)} {t('stats.unit_nm')}</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useCallback } from 'react'
|
import React, { useCallback } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { clampTankLiters } from '../utils/tankCapacity.js'
|
import { clampTankLiters } from '../utils/tankCapacity.js'
|
||||||
|
import { formatTankLiters, parseAppDecimalOrZero } from '../utils/numberFormat.js'
|
||||||
|
|
||||||
interface TankLiterInputProps {
|
interface TankLiterInputProps {
|
||||||
id?: string
|
id?: string
|
||||||
@@ -13,10 +14,8 @@ interface TankLiterInputProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseInputLiters(value: string): number {
|
function parseInputLiters(value: string): number {
|
||||||
const trimmed = value.trim().replace(',', '.')
|
if (!value.trim()) return 0
|
||||||
if (!trimmed) return 0
|
return parseAppDecimalOrZero(value)
|
||||||
const parsed = Number(trimmed)
|
|
||||||
return Number.isFinite(parsed) ? parsed : 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TankLiterInput({
|
export default function TankLiterInput({
|
||||||
@@ -34,8 +33,7 @@ export default function TankLiterInput({
|
|||||||
const emitValue = useCallback(
|
const emitValue = useCallback(
|
||||||
(liters: number) => {
|
(liters: number) => {
|
||||||
const clamped = clampTankLiters(liters, useSlider ? maxLiters : undefined)
|
const clamped = clampTankLiters(liters, useSlider ? maxLiters : undefined)
|
||||||
const str =
|
const str = formatTankLiters(clamped)
|
||||||
Number.isInteger(clamped) ? String(clamped) : String(Number(clamped.toFixed(1)))
|
|
||||||
onChange(str)
|
onChange(str)
|
||||||
},
|
},
|
||||||
[onChange, maxLiters, useSlider]
|
[onChange, maxLiters, useSlider]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
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 ThemedSelect from './ThemedSelect.tsx'
|
||||||
import PushNotificationSettings from './PushNotificationSettings.tsx'
|
import PushNotificationSettings from './PushNotificationSettings.tsx'
|
||||||
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
|
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
|
||||||
@@ -13,7 +13,9 @@ import {
|
|||||||
getThemePreference,
|
getThemePreference,
|
||||||
setColorSchemePreference,
|
setColorSchemePreference,
|
||||||
setOwmApiKey,
|
setOwmApiKey,
|
||||||
setThemePreference
|
setThemePreference,
|
||||||
|
getAiAuthorized,
|
||||||
|
setAiAuthorized
|
||||||
} from '../services/userPreferences.js'
|
} from '../services/userPreferences.js'
|
||||||
|
|
||||||
interface UserProfilePreferencesProps {
|
interface UserProfilePreferencesProps {
|
||||||
@@ -28,12 +30,25 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
|
|||||||
const [colorScheme, setColorScheme] = useState(() => getColorSchemePreference(userId))
|
const [colorScheme, setColorScheme] = useState(() => getColorSchemePreference(userId))
|
||||||
const [savingOwm, setSavingOwm] = useState(false)
|
const [savingOwm, setSavingOwm] = useState(false)
|
||||||
const [owmSaved, setOwmSaved] = useState(false)
|
const [owmSaved, setOwmSaved] = useState(false)
|
||||||
|
const [aiAuthorized, setAiAuthorizedState] = useState(() => getAiAuthorized(userId))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleChanged = () => {
|
||||||
|
setTheme(getThemePreference(userId))
|
||||||
|
setColorScheme(getColorSchemePreference(userId))
|
||||||
|
setAiAuthorizedState(getAiAuthorized(userId))
|
||||||
|
}
|
||||||
|
window.addEventListener('appearance-changed', handleChanged)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('appearance-changed', handleChanged)
|
||||||
|
}
|
||||||
|
}, [userId])
|
||||||
|
|
||||||
const persistAppearance = (nextTheme: string, nextColorScheme: string) => {
|
const persistAppearance = (nextTheme: string, nextColorScheme: string) => {
|
||||||
setThemePreference(userId, nextTheme)
|
setThemePreference(userId, nextTheme)
|
||||||
setColorSchemePreference(userId, nextColorScheme)
|
setColorSchemePreference(userId, nextColorScheme)
|
||||||
notifyAppearanceChanged()
|
notifyAppearanceChanged()
|
||||||
void saveAppearancePrefsToServer(nextTheme, nextColorScheme).catch((err) => {
|
void saveAppearancePrefsToServer(nextTheme, nextColorScheme, aiAuthorized, userId).catch((err) => {
|
||||||
console.warn('Failed to save appearance prefs to server:', err)
|
console.warn('Failed to save appearance prefs to server:', err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -58,6 +73,15 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
|
|||||||
window.setTimeout(() => setOwmSaved(false), 3000)
|
window.setTimeout(() => setOwmSaved(false), 3000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleAiToggle = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const nextVal = e.target.checked
|
||||||
|
setAiAuthorizedState(nextVal)
|
||||||
|
setAiAuthorized(userId, nextVal)
|
||||||
|
void saveAppearancePrefsToServer(theme, colorScheme, nextVal, userId).catch((err) => {
|
||||||
|
console.warn('Failed to save ai preference to server:', err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className="member-editor-card glass">
|
<section className="member-editor-card glass">
|
||||||
@@ -152,6 +176,42 @@ export default function UserProfilePreferences({ userId }: UserProfilePreference
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</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 />
|
<PushNotificationSettings />
|
||||||
<PwaInstallPrompt variant="inline" />
|
<PwaInstallPrompt variant="inline" />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ export default function VesselPoolForm() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn-icon logout"
|
className="btn-icon danger"
|
||||||
onClick={() => void handleDelete(v.payloadId)}
|
onClick={() => void handleDelete(v.payloadId)}
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+109
-21
@@ -6,6 +6,10 @@
|
|||||||
"beta": "Beta",
|
"beta": "Beta",
|
||||||
"beta_hint": "Betaversion - funktioner kan stadig ændres"
|
"beta_hint": "Betaversion - funktioner kan stadig ændres"
|
||||||
},
|
},
|
||||||
|
"footer": {
|
||||||
|
"kofi_label": "Ko-fi",
|
||||||
|
"kofi_title": "Støt projektet, videreudvikling og driftsomkostninger på Ko-fi"
|
||||||
|
},
|
||||||
"languages": {
|
"languages": {
|
||||||
"de": "Deutsch",
|
"de": "Deutsch",
|
||||||
"en": "English",
|
"en": "English",
|
||||||
@@ -39,7 +43,8 @@
|
|||||||
"deviation": "Tabel over distraktioner",
|
"deviation": "Tabel over distraktioner",
|
||||||
"logs": "Indlæg i logbogen",
|
"logs": "Indlæg i logbogen",
|
||||||
"stats": "Statistik",
|
"stats": "Statistik",
|
||||||
"settings": "Indstillinger"
|
"settings": "Indstillinger",
|
||||||
|
"admin": "Admin"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"welcome": "Velkommen til Kapteins Daagbok.",
|
"welcome": "Velkommen til Kapteins Daagbok.",
|
||||||
@@ -86,7 +91,15 @@
|
|||||||
"use_localhost_link": "Skift til localhost",
|
"use_localhost_link": "Skift til localhost",
|
||||||
"error_passkey_cancelled": "Passkey-login blev annulleret eller udløb. Prøv igen.",
|
"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_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": {
|
"pwa": {
|
||||||
"title": "Installer app",
|
"title": "Installer app",
|
||||||
@@ -172,7 +185,10 @@
|
|||||||
"travel_day_number": "Rejsedag {{number}}",
|
"travel_day_number": "Rejsedag {{number}}",
|
||||||
"departure": "Starthavn (rejse fra)",
|
"departure": "Starthavn (rejse fra)",
|
||||||
"destination": "Destinationsport (til)",
|
"destination": "Destinationsport (til)",
|
||||||
"route": "Rejse fra/til",
|
"route": "Reje fra/til",
|
||||||
|
"tanks": "Tanke",
|
||||||
|
"customize_columns": "Tilpas kolonner",
|
||||||
|
"column_selector_title": "Kolonner at vise",
|
||||||
"freshwater": "Ferskvand (liter)",
|
"freshwater": "Ferskvand (liter)",
|
||||||
"fuel": "Treibstoff / Brændstof (liter)",
|
"fuel": "Treibstoff / Brændstof (liter)",
|
||||||
"greywater": "Gråt vand (liter)",
|
"greywater": "Gråt vand (liter)",
|
||||||
@@ -245,13 +261,13 @@
|
|||||||
"live_sails_confirm": "Indtast",
|
"live_sails_confirm": "Indtast",
|
||||||
"live_sails_confirm_count": "Indtast ({{count}})",
|
"live_sails_confirm_count": "Indtast ({{count}})",
|
||||||
"live_sails": "Sejl: {{sails}}",
|
"live_sails": "Sejl: {{sails}}",
|
||||||
"live_fix": "Fix",
|
"live_position": "Position",
|
||||||
"live_fix_coords": "Fix {{lat}}, {{lng}}",
|
"live_position_coords": "Position {{lat}}, {{lng}}",
|
||||||
"live_fix_manual_hint": "GPS ikke tilgængelig. Indtast bredde- og længdegrad manuelt, eller prøv igen med GPS-knappen.",
|
"live_position_manual_hint": "GPS ikke tilgængelig. Indtast bredde- og længdegrad manuelt, eller prøv igen med GPS-knappen.",
|
||||||
"live_fix_gps_loading": "Henter GPS-position…",
|
"live_position_gps_loading": "Henter GPS-position…",
|
||||||
"live_fix_invalid": "Indtast gyldige koordinater (bredde −90…90, længde −180…180).",
|
"live_position_invalid": "Indtast gyldige koordinater (bredde −90…90, længde −180…180).",
|
||||||
"live_fix_lat_placeholder": "Bredde (Lat)",
|
"live_position_lat_placeholder": "Bredde (Lat)",
|
||||||
"live_fix_lng_placeholder": "Længde (Lng)",
|
"live_position_lng_placeholder": "Længde (Lng)",
|
||||||
"live_photo_btn": "Foto (kamera)",
|
"live_photo_btn": "Foto (kamera)",
|
||||||
"live_photo_capture_btn": "Tag billede",
|
"live_photo_capture_btn": "Tag billede",
|
||||||
"live_photo_save_btn": "Gem",
|
"live_photo_save_btn": "Gem",
|
||||||
@@ -262,10 +278,32 @@
|
|||||||
"live_photo_camera_starting": "Starter kamera…",
|
"live_photo_camera_starting": "Starter kamera…",
|
||||||
"live_photo_camera_denied": "Kameraadgang nægtet eller utilgængelig.",
|
"live_photo_camera_denied": "Kameraadgang nægtet eller utilgængelig.",
|
||||||
"live_photo_camera_unavailable": "Kamera understøttes ikke i denne browser.",
|
"live_photo_camera_unavailable": "Kamera understøttes ikke i denne browser.",
|
||||||
|
"live_photo_no_camera": "Der er intet kamera tilgængeligt på denne enhed.",
|
||||||
"live_photo_error": "Foto kunne ikke gemmes.",
|
"live_photo_error": "Foto kunne ikke gemmes.",
|
||||||
"live_photo_entry": "Foto: {{caption}}",
|
"live_photo_entry": "Foto: {{caption}}",
|
||||||
"live_photo_entry_plain": "Foto taget",
|
"live_photo_entry_plain": "Foto taget",
|
||||||
"live_undo_photo_hint": "Foto gemt",
|
"live_undo_photo_hint": "Foto gemt",
|
||||||
|
"live_voice_btn": "Stemmenotat",
|
||||||
|
"live_voice_hint": "Optag en kort stemmenotat (maks. 60 sekunder).",
|
||||||
|
"live_voice_record": "Start optagelse",
|
||||||
|
"live_voice_stop": "Stop optagelse",
|
||||||
|
"live_voice_recording": "Optager {{time}}",
|
||||||
|
"live_voice_save": "Gem",
|
||||||
|
"live_voice_saving": "Gemmer…",
|
||||||
|
"live_voice_retake": "Optag igen",
|
||||||
|
"live_voice_mic_denied": "Mikrofonadgang nægtet eller utilgængelig.",
|
||||||
|
"live_voice_record_failed": "Optagelse mislykkedes. Prøv igen.",
|
||||||
|
"live_voice_unavailable": "Stemmenotat utilgængelig",
|
||||||
|
"live_voice_too_large": "Optagelsen er for stor. Optag venligst kortere.",
|
||||||
|
"live_voice_error": "Kunne ikke gemme stemmenotat.",
|
||||||
|
"live_voice_entry": "Stemmenotat: {{caption}}",
|
||||||
|
"live_voice_entry_plain": "Stemmenotat",
|
||||||
|
"live_voice_caption_label": "Billedtekst (valgfrit)",
|
||||||
|
"live_voice_caption_placeholder": "f.eks. radiokontakt med havnemester",
|
||||||
|
"live_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_btn": "Kommentar",
|
||||||
"live_comment_placeholder": "Indtast tekst…",
|
"live_comment_placeholder": "Indtast tekst…",
|
||||||
"live_comment_confirm": "Indtast",
|
"live_comment_confirm": "Indtast",
|
||||||
@@ -275,8 +313,8 @@
|
|||||||
"live_weather_btn": "Vejr",
|
"live_weather_btn": "Vejr",
|
||||||
"live_weather_owm_btn": "Hent OpenWeatherMap-vejr",
|
"live_weather_owm_btn": "Hent OpenWeatherMap-vejr",
|
||||||
"live_weather_owm_loading": "Henter vejr…",
|
"live_weather_owm_loading": "Henter vejr…",
|
||||||
"live_weather_fix_required": "Log først en GPS-fix (Fix-knap) for at hente OpenWeatherMap-vejr. Positionen må højst være 6 timer gammel.",
|
"live_weather_position_required": "Log først en position (Position-knap) for at hente OpenWeatherMap-vejr. Positionen må højst være 6 timer gammel.",
|
||||||
"live_weather_fix_stale": "Den seneste GPS-fix er ældre end 6 timer. Log en ny fix, før du henter vejr.",
|
"live_weather_position_stale": "Den seneste position er ældre end 6 timer. Log en ny position, før du henter vejr.",
|
||||||
"live_wind_btn": "Vind",
|
"live_wind_btn": "Vind",
|
||||||
"live_temp_btn": "T °C",
|
"live_temp_btn": "T °C",
|
||||||
"live_pressure_btn": "Lufttryk",
|
"live_pressure_btn": "Lufttryk",
|
||||||
@@ -284,8 +322,8 @@
|
|||||||
"live_sea_state_btn": "Søgang",
|
"live_sea_state_btn": "Søgang",
|
||||||
"live_visibility_btn": "Sigtbarhed",
|
"live_visibility_btn": "Sigtbarhed",
|
||||||
"live_course_btn": "Kurs",
|
"live_course_btn": "Kurs",
|
||||||
"live_fuel_btn": "Diesel",
|
"live_fuel_btn": "+ Diesel",
|
||||||
"live_water_btn": "Vand",
|
"live_water_btn": "+ Vand",
|
||||||
"live_wind_entry": "Vind {{value}}",
|
"live_wind_entry": "Vind {{value}}",
|
||||||
"live_temp_entry": "Temperatur {{temp}} °C",
|
"live_temp_entry": "Temperatur {{temp}} °C",
|
||||||
"live_pressure_entry": "Lufttryk {{value}} hPa",
|
"live_pressure_entry": "Lufttryk {{value}} hPa",
|
||||||
@@ -298,6 +336,7 @@
|
|||||||
"live_auto_position": "Auto-position",
|
"live_auto_position": "Auto-position",
|
||||||
"live_undo_hint": "Indtastning gemt",
|
"live_undo_hint": "Indtastning gemt",
|
||||||
"live_undo_btn": "Fortryd",
|
"live_undo_btn": "Fortryd",
|
||||||
|
"live_cancel": "Annuller",
|
||||||
"live_pressure_placeholder": "f.eks. 1013",
|
"live_pressure_placeholder": "f.eks. 1013",
|
||||||
"live_temp_placeholder": "f.eks. 18",
|
"live_temp_placeholder": "f.eks. 18",
|
||||||
"live_precip_placeholder": "f.eks. let regn",
|
"live_precip_placeholder": "f.eks. let regn",
|
||||||
@@ -320,6 +359,7 @@
|
|||||||
"carry_over_tanks_yes": "Tag over",
|
"carry_over_tanks_yes": "Tag over",
|
||||||
"carry_over_tanks_no": "Start med 0",
|
"carry_over_tanks_no": "Start med 0",
|
||||||
"event_title": "Kronologisk hændelseslog",
|
"event_title": "Kronologisk hændelseslog",
|
||||||
|
"event_creator": "Indtastet af",
|
||||||
"no_events": "Der er endnu ikke indtastet nogen begivenheder for denne rejsedag.",
|
"no_events": "Der er endnu ikke indtastet nogen begivenheder for denne rejsedag.",
|
||||||
"event_time": "Tidspunkt på dagen",
|
"event_time": "Tidspunkt på dagen",
|
||||||
"event_mgk": "MgK-kursus",
|
"event_mgk": "MgK-kursus",
|
||||||
@@ -357,7 +397,26 @@
|
|||||||
"event_location_placeholder": "z. f.eks. Kiel",
|
"event_location_placeholder": "z. f.eks. Kiel",
|
||||||
"event_remarks": "Bemærkninger / hændelser",
|
"event_remarks": "Bemærkninger / hændelser",
|
||||||
"gps_btn": "Hent GPS-koordinater",
|
"gps_btn": "Hent GPS-koordinater",
|
||||||
|
"gps_permission_denied": "Adgang til placering blev nægtet. Tillad det i browser- eller enhedsindstillinger og prøv igen.",
|
||||||
|
"gps_timeout": "GPS fik timeout. Prøv igen udendørs med frit udsyn til himlen.",
|
||||||
|
"gps_position_unavailable": "Intet GPS-signal tilgængeligt. Vent og prøv igen, eller indtast koordinater manuelt.",
|
||||||
|
"gps_unavailable": "GPS understøttes ikke af denne browser eller enhed.",
|
||||||
|
"gps_failed": "GPS-position kunne ikke bestemmes.",
|
||||||
|
"gps_fallback_no_location": "GPS mislykkedes. Angiv et sted under placering/havn, afgang eller destination, eller indtast koordinater manuelt.",
|
||||||
|
"gps_fallback_success": "Koordinater for \"{{location}}\" fundet via stedsnavn (ikke GPS).",
|
||||||
|
"gps_fallback_failed": "GPS og stedsnavnssøgning mislykkedes. Indtast koordinater manuelt.",
|
||||||
|
"gps_quality_excellent": "Stærk GPS-modtagelse (±{{accuracy}} m)",
|
||||||
|
"gps_quality_good": "God GPS-modtagelse (±{{accuracy}} m)",
|
||||||
|
"gps_quality_fair": "Middel GPS-modtagelse (±{{accuracy}} m) – gå udendørs for bedre signal.",
|
||||||
|
"gps_quality_poor": "Svag GPS-modtagelse (±{{accuracy}} m) – sandsynligvis få satellitter. Prøv udendørs igen eller kontroller positionen.",
|
||||||
|
"gps_quality_unknown": "GPS-position overtaget (nøjagtighed ikke rapporteret af enheden).",
|
||||||
|
"gps_live_intro_title": "Placering til live-log",
|
||||||
|
"gps_live_intro_body": "Appen har brug for din placering til automatiske positionsindlæg og GPS-knappen.\n\nTryk på „Tillad placering“ og bekræft i den næste dialog. Du kan altid indtaste position manuelt via „Position“.",
|
||||||
|
"gps_live_intro_allow": "Tillad placering",
|
||||||
|
"gps_live_intro_later": "Senere",
|
||||||
|
"gps_enable_in_settings_hint": "Adgang til placering er blokeret. Du kan tillade det senere i browser- eller enhedsindstillinger (websted / app → Placering).",
|
||||||
"weather_btn": "OpenWeatherMap Kald vejret op",
|
"weather_btn": "OpenWeatherMap Kald vejret op",
|
||||||
|
"weather_offline": "OpenWeatherMap kræver internetforbindelse. Du er offline lige nu.",
|
||||||
"event_wind_pressure": "Lufttryk (hPa)",
|
"event_wind_pressure": "Lufttryk (hPa)",
|
||||||
"event_heel": "Krængning (°)",
|
"event_heel": "Krængning (°)",
|
||||||
"event_sails": "Sejlhåndtering/motor",
|
"event_sails": "Sejlhåndtering/motor",
|
||||||
@@ -371,10 +430,24 @@
|
|||||||
"share_csv": "CSV andel",
|
"share_csv": "CSV andel",
|
||||||
"export_pdf": "Download PDF.",
|
"export_pdf": "Download PDF.",
|
||||||
"exporting_pdf": "PDF er genereret...",
|
"exporting_pdf": "PDF er genereret...",
|
||||||
"photos_title": "Vedhæftede billeder (E2E-krypteret)",
|
"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",
|
||||||
"photo_caption_label": "Fotobeskrivelse/etiket (valgfrit)",
|
"photo_caption_label": "Fotobeskrivelse/etiket (valgfrit)",
|
||||||
"photo_caption_placeholder": "f.eks. at sætte sejl nær indsejlingen til havnen",
|
"photo_caption_placeholder": "f.eks. at sætte sejl nær indsejlingen til havnen",
|
||||||
"photo_btn": "Tag foto / upload",
|
"photo_btn": "Tag foto / upload",
|
||||||
|
"photo_camera_btn": "Tag foto",
|
||||||
|
"photo_gallery_btn": "Vælg fra galleri",
|
||||||
"photo_processing": "Er ved at blive behandlet...",
|
"photo_processing": "Er ved at blive behandlet...",
|
||||||
"no_photos": "Der er endnu ingen billeder knyttet til denne rejsedag.",
|
"no_photos": "Der er endnu ingen billeder knyttet til denne rejsedag.",
|
||||||
"photo_delete_confirm": "Er du sikker på, at du vil slette dette foto permanent?",
|
"photo_delete_confirm": "Er du sikker på, at du vil slette dette foto permanent?",
|
||||||
@@ -449,8 +522,8 @@
|
|||||||
"nmea_change_engine_stop": "Engine off",
|
"nmea_change_engine_stop": "Engine off",
|
||||||
"nmea_change_autopilot_on": "Autopilot on",
|
"nmea_change_autopilot_on": "Autopilot on",
|
||||||
"nmea_change_autopilot_off": "Autopilot off",
|
"nmea_change_autopilot_off": "Autopilot off",
|
||||||
"nmea_change_gps_lost": "GPS fix lost",
|
"nmea_change_gps_lost": "GPS-position mistet",
|
||||||
"nmea_change_gps_regained": "GPS fix restored",
|
"nmea_change_gps_regained": "GPS-position gendannet",
|
||||||
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
|
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
|
||||||
"nmea_change_departure": "Departure / underway",
|
"nmea_change_departure": "Departure / underway",
|
||||||
"nmea_change_anchor": "Anchored / stop",
|
"nmea_change_anchor": "Anchored / stop",
|
||||||
@@ -464,7 +537,7 @@
|
|||||||
"new_logbook_placeholder": "Navn på logbog eller yacht",
|
"new_logbook_placeholder": "Navn på logbog eller yacht",
|
||||||
"logout": "Log ud",
|
"logout": "Log ud",
|
||||||
"logged_in_as": "Logget ind som {{name}}",
|
"logged_in_as": "Logget ind som {{name}}",
|
||||||
"delete_confirm": "Er du sikker på, at du vil slette denne logbog permanent? Alle lokale data og serverkopier vil blive destrueret.\n\nTip: Lav en sikkerhedskopi (.daagbok.json) på forhånd under Indstillinger → Sikkerhedskopiering og gendannelse, hvis du vil beholde dataene senere.",
|
"delete_confirm": "Er du sikker på, at du vil slette denne logbog permanent? Alle lokale data og serverkopier vil blive destrueret.\n\nTip: Lav en sikkerhedskopi (.daagbok) på forhånd under Indstillinger → Sikkerhedskopiering og gendannelse, hvis du vil beholde dataene senere.",
|
||||||
"no_logbooks": "Ingen logbøger fundet. Opret din første logbog for at komme i gang!",
|
"no_logbooks": "Ingen logbøger fundet. Opret din første logbog for at komme i gang!",
|
||||||
"loading": "Logbøgerne er fyldt op...",
|
"loading": "Logbøgerne er fyldt op...",
|
||||||
"status_synced": "Synkroniseret",
|
"status_synced": "Synkroniseret",
|
||||||
@@ -604,6 +677,12 @@
|
|||||||
"integrations_title": "Integrationer",
|
"integrations_title": "Integrationer",
|
||||||
"owm_key": "OpenWeatherMap API-nøgle",
|
"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.",
|
"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_save": "Gemme",
|
||||||
"prefs_saving": "Vil blive reddet...",
|
"prefs_saving": "Vil blive reddet...",
|
||||||
"prefs_saved": "Gemt",
|
"prefs_saved": "Gemt",
|
||||||
@@ -725,6 +804,9 @@
|
|||||||
"no_key": "Ingen OpenWeatherMap API-nøgle tilgængelig. Gem din egen nøgle i brugerprofilen, eller kontakt operatøren.",
|
"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_success": "Vejrdata hentet med succes!",
|
||||||
"weather_error": "Hentning af vejrdata mislykkedes. Tjek API-nøglen og forbindelsen.",
|
"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}}.",
|
"weather_date_mismatch": "Vejrdata kan kun hentes for i dag ({{today}}). Dette logbogsindlæg er dateret {{date}}.",
|
||||||
"gps_error": "Indtast en placering, eller find GPS-koordinaterne.",
|
"gps_error": "Indtast en placering, eller find GPS-koordinaterne.",
|
||||||
"share_title": "Del logbog (skrivebeskyttet)",
|
"share_title": "Del logbog (skrivebeskyttet)",
|
||||||
@@ -743,7 +825,7 @@
|
|||||||
"delete_account_confirm_yes": "Ja, slet konto og alle data",
|
"delete_account_confirm_yes": "Ja, slet konto og alle data",
|
||||||
"delete_account_confirm_no": "Annuller",
|
"delete_account_confirm_no": "Annuller",
|
||||||
"delete_account_failed": "Kontoen kunne ikke slettes. Prøv venligst igen.",
|
"delete_account_failed": "Kontoen kunne ikke slettes. Prøv venligst igen.",
|
||||||
"delete_backup_hint": "Tip: Lav sikkerhedskopier af dine logbøger (.daagbok.json) i indstillingerne for hver logbog, før du sletter dem.",
|
"delete_backup_hint": "Tip: Lav sikkerhedskopier af dine logbøger (.daagbok) i indstillingerne for hver logbog, før du sletter dem.",
|
||||||
"deleting_account": "Kontoen vil blive slettet...",
|
"deleting_account": "Kontoen vil blive slettet...",
|
||||||
"invite_push_prompt_title": "Aktivere push-meddelelser?",
|
"invite_push_prompt_title": "Aktivere push-meddelelser?",
|
||||||
"invite_push_prompt_message": "Så snart inviterede Crew-medlemmer synkroniserer ændringer, kan du blive informeret via push. Intet logbogsindhold sendes i almindelig tekst.",
|
"invite_push_prompt_message": "Så snart inviterede Crew-medlemmer synkroniserer ændringer, kan du blive informeret via push. Intet logbogsindhold sendes i almindelig tekst.",
|
||||||
@@ -754,7 +836,7 @@
|
|||||||
"backup_title": "Sikkerhedskopiering og gendannelse",
|
"backup_title": "Sikkerhedskopiering og gendannelse",
|
||||||
"backup_desc": "Komplet krypteret backup af denne logbog (poster, fotos, GPS-spor, crew, skib). Beskyttet med backup-passphrase - til gendannelse til denne eller en ny konto.",
|
"backup_desc": "Komplet krypteret backup af denne logbog (poster, fotos, GPS-spor, crew, skib). Beskyttet med backup-passphrase - til gendannelse til denne eller en ny konto.",
|
||||||
"backup_export_title": "Opret backup",
|
"backup_export_title": "Opret backup",
|
||||||
"backup_export_desc": "Downloader alle lokale data som .daagbok.json. Hold filen og adgangssætningen adskilt og sikker.",
|
"backup_export_desc": "Downloader alle lokale data som et komprimeret .daagbok-arkiv. Hold filen og adgangssætningen adskilt og sikker.",
|
||||||
"backup_restore_title": "Gendan sikkerhedskopi",
|
"backup_restore_title": "Gendan sikkerhedskopi",
|
||||||
"backup_restore_desc": "Gendanner en sikkerhedskopi til din nuværende konto - selv efter registrering af en ny konto.",
|
"backup_restore_desc": "Gendanner en sikkerhedskopi til din nuværende konto - selv efter registrering af en ny konto.",
|
||||||
"backup_passphrase": "Backup-passphrase",
|
"backup_passphrase": "Backup-passphrase",
|
||||||
@@ -766,7 +848,13 @@
|
|||||||
"backup_export_btn": "Download backup",
|
"backup_export_btn": "Download backup",
|
||||||
"backup_exporting": "Sikkerhedskopien er oprettet...",
|
"backup_exporting": "Sikkerhedskopien er oprettet...",
|
||||||
"backup_export_success": "Backup oprettet ({{count}} rejsedage).",
|
"backup_export_success": "Backup oprettet ({{count}} rejsedage).",
|
||||||
"backup_file_label": "Backup-fil (.daagbok.json)",
|
"backup_file_label": "Backup-fil (.daagbok)",
|
||||||
|
"backup_export_progress": "Pakker filer {{current}} / {{total}}…",
|
||||||
|
"backup_invalid_archive": "Filen er ikke et gyldigt backup-arkiv.",
|
||||||
|
"backup_version_unsupported": "Gammelt backup-format (v1). Brug en aktuel .daagbok-backup.",
|
||||||
|
"backup_import_size_confirm": "Denne backup er ca. {{size}} ukomprimeret. Gendannelse kan tage længere tid. Fortsæt?",
|
||||||
|
"backup_stat_voice": "{{count}} stemmenotater",
|
||||||
|
"backup_stat_size": "Ca. {{size}} ukomprimeret",
|
||||||
"backup_preview_btn": "Tjek indhold",
|
"backup_preview_btn": "Tjek indhold",
|
||||||
"backup_previewing": "Tjek...",
|
"backup_previewing": "Tjek...",
|
||||||
"backup_restore_btn": "Gendan",
|
"backup_restore_btn": "Gendan",
|
||||||
|
|||||||
+110
-22
@@ -6,6 +6,10 @@
|
|||||||
"beta": "Beta",
|
"beta": "Beta",
|
||||||
"beta_hint": "Beta-Version — Funktionen können sich noch ändern"
|
"beta_hint": "Beta-Version — Funktionen können sich noch ändern"
|
||||||
},
|
},
|
||||||
|
"footer": {
|
||||||
|
"kofi_label": "Ko-fi",
|
||||||
|
"kofi_title": "Projekt, Weiterentwicklung und Betriebskosten auf Ko-fi unterstützen"
|
||||||
|
},
|
||||||
"languages": {
|
"languages": {
|
||||||
"de": "Deutsch",
|
"de": "Deutsch",
|
||||||
"en": "English",
|
"en": "English",
|
||||||
@@ -39,7 +43,8 @@
|
|||||||
"deviation": "Ablenkungstabelle",
|
"deviation": "Ablenkungstabelle",
|
||||||
"logs": "Logbucheinträge",
|
"logs": "Logbucheinträge",
|
||||||
"stats": "Statistik",
|
"stats": "Statistik",
|
||||||
"settings": "Einstellungen"
|
"settings": "Einstellungen",
|
||||||
|
"admin": "Admin"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"welcome": "Willkommen bei Kapteins Daagbok",
|
"welcome": "Willkommen bei Kapteins Daagbok",
|
||||||
@@ -86,7 +91,15 @@
|
|||||||
"use_localhost_link": "Zu localhost wechseln",
|
"use_localhost_link": "Zu localhost wechseln",
|
||||||
"error_passkey_cancelled": "Passkey-Anmeldung abgebrochen oder abgelaufen. Bitte erneut versuchen.",
|
"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_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": {
|
"pwa": {
|
||||||
"title": "App installieren",
|
"title": "App installieren",
|
||||||
@@ -173,6 +186,9 @@
|
|||||||
"departure": "Start-Hafen (Reise von)",
|
"departure": "Start-Hafen (Reise von)",
|
||||||
"destination": "Ziel-Hafen (nach)",
|
"destination": "Ziel-Hafen (nach)",
|
||||||
"route": "Reise von/nach",
|
"route": "Reise von/nach",
|
||||||
|
"tanks": "Tanks",
|
||||||
|
"customize_columns": "Spalten anpassen",
|
||||||
|
"column_selector_title": "Anzuzeigende Spalten",
|
||||||
"freshwater": "Frischwasser (Liter)",
|
"freshwater": "Frischwasser (Liter)",
|
||||||
"fuel": "Treibstoff / Fuel (Liter)",
|
"fuel": "Treibstoff / Fuel (Liter)",
|
||||||
"greywater": "Grauwasser (Liter)",
|
"greywater": "Grauwasser (Liter)",
|
||||||
@@ -245,13 +261,13 @@
|
|||||||
"live_sails_confirm": "Eintragen",
|
"live_sails_confirm": "Eintragen",
|
||||||
"live_sails_confirm_count": "Eintragen ({{count}})",
|
"live_sails_confirm_count": "Eintragen ({{count}})",
|
||||||
"live_sails": "Segel: {{sails}}",
|
"live_sails": "Segel: {{sails}}",
|
||||||
"live_fix": "Fix",
|
"live_position": "Position",
|
||||||
"live_fix_coords": "Fix {{lat}}, {{lng}}",
|
"live_position_coords": "Position {{lat}}, {{lng}}",
|
||||||
"live_fix_manual_hint": "GPS nicht verfügbar. Breiten- und Längengrad manuell eingeben oder erneut per GPS-Knopf versuchen.",
|
"live_position_manual_hint": "GPS nicht verfügbar. Breiten- und Längengrad manuell eingeben oder erneut per GPS-Knopf versuchen.",
|
||||||
"live_fix_gps_loading": "GPS-Position wird ermittelt…",
|
"live_position_gps_loading": "GPS-Position wird ermittelt…",
|
||||||
"live_fix_invalid": "Bitte gültige Koordinaten eingeben (Breite −90…90, Länge −180…180).",
|
"live_position_invalid": "Bitte gültige Koordinaten eingeben (Breite −90…90, Länge −180…180).",
|
||||||
"live_fix_lat_placeholder": "Breite (Lat)",
|
"live_position_lat_placeholder": "Breite (Lat)",
|
||||||
"live_fix_lng_placeholder": "Länge (Lng)",
|
"live_position_lng_placeholder": "Länge (Lng)",
|
||||||
"live_photo_btn": "Foto (Kamera)",
|
"live_photo_btn": "Foto (Kamera)",
|
||||||
"live_photo_capture_btn": "Aufnehmen",
|
"live_photo_capture_btn": "Aufnehmen",
|
||||||
"live_photo_save_btn": "Speichern",
|
"live_photo_save_btn": "Speichern",
|
||||||
@@ -262,21 +278,43 @@
|
|||||||
"live_photo_camera_starting": "Kamera wird gestartet…",
|
"live_photo_camera_starting": "Kamera wird gestartet…",
|
||||||
"live_photo_camera_denied": "Kamerazugriff verweigert oder nicht verfügbar.",
|
"live_photo_camera_denied": "Kamerazugriff verweigert oder nicht verfügbar.",
|
||||||
"live_photo_camera_unavailable": "Kamera wird von diesem Browser nicht unterstützt.",
|
"live_photo_camera_unavailable": "Kamera wird von diesem Browser nicht unterstützt.",
|
||||||
|
"live_photo_no_camera": "Auf diesem Gerät ist keine Kamera verfügbar.",
|
||||||
"live_photo_error": "Foto konnte nicht gespeichert werden.",
|
"live_photo_error": "Foto konnte nicht gespeichert werden.",
|
||||||
"live_photo_entry": "Foto: {{caption}}",
|
"live_photo_entry": "Foto: {{caption}}",
|
||||||
"live_photo_entry_plain": "Foto aufgenommen",
|
"live_photo_entry_plain": "Foto aufgenommen",
|
||||||
"live_undo_photo_hint": "Foto gespeichert",
|
"live_undo_photo_hint": "Foto gespeichert",
|
||||||
|
"live_voice_btn": "Sprachnotiz",
|
||||||
|
"live_voice_hint": "Kurze Sprachnotiz aufnehmen (max. 60 Sekunden).",
|
||||||
|
"live_voice_record": "Aufnahme starten",
|
||||||
|
"live_voice_stop": "Aufnahme beenden",
|
||||||
|
"live_voice_recording": "Aufnahme {{time}}",
|
||||||
|
"live_voice_save": "Speichern",
|
||||||
|
"live_voice_saving": "Wird gespeichert…",
|
||||||
|
"live_voice_retake": "Neu aufnehmen",
|
||||||
|
"live_voice_mic_denied": "Mikrofonzugriff verweigert oder nicht verfügbar.",
|
||||||
|
"live_voice_record_failed": "Aufnahme fehlgeschlagen. Bitte erneut versuchen.",
|
||||||
|
"live_voice_unavailable": "Sprachnotiz nicht verfügbar",
|
||||||
|
"live_voice_too_large": "Aufnahme ist zu groß. Bitte kürzer aufnehmen.",
|
||||||
|
"live_voice_error": "Sprachnotiz konnte nicht gespeichert werden.",
|
||||||
|
"live_voice_entry": "Sprachnotiz: {{caption}}",
|
||||||
|
"live_voice_entry_plain": "Sprachnotiz",
|
||||||
|
"live_voice_caption_label": "Beschriftung (optional)",
|
||||||
|
"live_voice_caption_placeholder": "z. B. Funkverkehr mit Hafenmeister",
|
||||||
|
"live_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_btn": "Kommentar",
|
||||||
"live_comment_placeholder": "Freitext eingeben…",
|
"live_comment_placeholder": "Freitext eingeben…",
|
||||||
"live_comment_confirm": "Eintragen",
|
"live_comment_confirm": "Eintragen",
|
||||||
"live_gps_error": "GPS-Position konnte nicht ermittelt werden.",
|
"live_gps_error": "GPS-Position konnte nicht ermittelt werden.",
|
||||||
"live_gps_start_hint": "Beginne deine Tagesreise immer mit einem Standort.",
|
"live_gps_start_hint": "Beginne deine Tagesreise immer mit einer Position.",
|
||||||
"live_event_generic": "Ereignis",
|
"live_event_generic": "Ereignis",
|
||||||
"live_weather_btn": "Wetter",
|
"live_weather_btn": "Wetter",
|
||||||
"live_weather_owm_btn": "OpenWeatherMap Wetter abrufen",
|
"live_weather_owm_btn": "OpenWeatherMap Wetter abrufen",
|
||||||
"live_weather_owm_loading": "Wetter wird geladen…",
|
"live_weather_owm_loading": "Wetter wird geladen…",
|
||||||
"live_weather_fix_required": "Für Wetter von OpenWeatherMap zuerst einen GPS-Fix eintragen (Schaltfläche „Fix“). Die Position darf höchstens 6 Stunden alt sein.",
|
"live_weather_position_required": "Für Wetter von OpenWeatherMap zuerst eine Position eintragen (Schaltfläche „Position“). Die Position darf höchstens 6 Stunden alt sein.",
|
||||||
"live_weather_fix_stale": "Der letzte GPS-Fix ist älter als 6 Stunden. Bitte erneut einen Fix loggen, bevor du Wetter abrufst.",
|
"live_weather_position_stale": "Die letzte Position ist älter als 6 Stunden. Bitte erneut eine Position loggen, bevor du Wetter abrufst.",
|
||||||
"live_wind_btn": "Wind",
|
"live_wind_btn": "Wind",
|
||||||
"live_temp_btn": "T °C",
|
"live_temp_btn": "T °C",
|
||||||
"live_pressure_btn": "Luftdruck",
|
"live_pressure_btn": "Luftdruck",
|
||||||
@@ -284,8 +322,8 @@
|
|||||||
"live_sea_state_btn": "Seegang",
|
"live_sea_state_btn": "Seegang",
|
||||||
"live_visibility_btn": "Sichtweite",
|
"live_visibility_btn": "Sichtweite",
|
||||||
"live_course_btn": "Kurs",
|
"live_course_btn": "Kurs",
|
||||||
"live_fuel_btn": "Diesel",
|
"live_fuel_btn": "+ Diesel",
|
||||||
"live_water_btn": "Wasser",
|
"live_water_btn": "+ Wasser",
|
||||||
"live_wind_entry": "Wind {{value}}",
|
"live_wind_entry": "Wind {{value}}",
|
||||||
"live_temp_entry": "Temperatur {{temp}} °C",
|
"live_temp_entry": "Temperatur {{temp}} °C",
|
||||||
"live_pressure_entry": "Luftdruck {{value}} hPa",
|
"live_pressure_entry": "Luftdruck {{value}} hPa",
|
||||||
@@ -298,6 +336,7 @@
|
|||||||
"live_auto_position": "Auto-Position",
|
"live_auto_position": "Auto-Position",
|
||||||
"live_undo_hint": "Eintrag gespeichert",
|
"live_undo_hint": "Eintrag gespeichert",
|
||||||
"live_undo_btn": "Rückgängig",
|
"live_undo_btn": "Rückgängig",
|
||||||
|
"live_cancel": "Abbruch",
|
||||||
"live_pressure_placeholder": "z. B. 1013",
|
"live_pressure_placeholder": "z. B. 1013",
|
||||||
"live_temp_placeholder": "z. B. 18",
|
"live_temp_placeholder": "z. B. 18",
|
||||||
"live_precip_placeholder": "z. B. leichter Regen",
|
"live_precip_placeholder": "z. B. leichter Regen",
|
||||||
@@ -320,6 +359,7 @@
|
|||||||
"carry_over_tanks_yes": "Übernehmen",
|
"carry_over_tanks_yes": "Übernehmen",
|
||||||
"carry_over_tanks_no": "Mit 0 starten",
|
"carry_over_tanks_no": "Mit 0 starten",
|
||||||
"event_title": "Chronologisches Ereignisprotokoll",
|
"event_title": "Chronologisches Ereignisprotokoll",
|
||||||
|
"event_creator": "Eingetragen von",
|
||||||
"no_events": "Noch keine Ereignisse für diesen Reisetag eingetragen.",
|
"no_events": "Noch keine Ereignisse für diesen Reisetag eingetragen.",
|
||||||
"event_time": "Uhrzeit",
|
"event_time": "Uhrzeit",
|
||||||
"event_mgk": "MgK Kurs",
|
"event_mgk": "MgK Kurs",
|
||||||
@@ -357,7 +397,26 @@
|
|||||||
"event_location_placeholder": "z. B. Kiel",
|
"event_location_placeholder": "z. B. Kiel",
|
||||||
"event_remarks": "Bemerkungen / Vorkommnisse",
|
"event_remarks": "Bemerkungen / Vorkommnisse",
|
||||||
"gps_btn": "GPS-Koordinaten abrufen",
|
"gps_btn": "GPS-Koordinaten abrufen",
|
||||||
|
"gps_permission_denied": "Standortzugriff wurde verweigert. Bitte in den Browser- oder Geräteeinstellungen erlauben und erneut versuchen.",
|
||||||
|
"gps_timeout": "GPS-Zeitüberschreitung. Bitte erneut versuchen – am besten im Freien mit gutem Empfang.",
|
||||||
|
"gps_position_unavailable": "Kein GPS-Signal verfügbar. Bitte warten oder Koordinaten manuell eingeben.",
|
||||||
|
"gps_unavailable": "GPS wird von diesem Browser oder Gerät nicht unterstützt.",
|
||||||
|
"gps_failed": "GPS-Position konnte nicht ermittelt werden.",
|
||||||
|
"gps_fallback_no_location": "GPS fehlgeschlagen. Bitte einen Ort unter „Ort / Hafen“, Start- oder Zielhafen eintragen, oder Koordinaten manuell eingeben.",
|
||||||
|
"gps_fallback_success": "Koordinaten für „{{location}}“ über den Ortsnamen ermittelt (nicht per GPS).",
|
||||||
|
"gps_fallback_failed": "GPS und Ortsnamen-Suche sind fehlgeschlagen. Bitte Koordinaten manuell eingeben.",
|
||||||
|
"gps_quality_excellent": "Starker GPS-Empfang (±{{accuracy}} m)",
|
||||||
|
"gps_quality_good": "Guter GPS-Empfang (±{{accuracy}} m)",
|
||||||
|
"gps_quality_fair": "Mäßiger GPS-Empfang (±{{accuracy}} m) – für besseren Empfang ins Freie gehen.",
|
||||||
|
"gps_quality_poor": "Schwacher GPS-Empfang (±{{accuracy}} m) – vermutlich wenig Satelliten. Im Freien erneut versuchen oder Position prüfen.",
|
||||||
|
"gps_quality_unknown": "GPS-Position übernommen (Genauigkeit vom Gerät nicht gemeldet).",
|
||||||
|
"gps_live_intro_title": "Standort für Live-Log",
|
||||||
|
"gps_live_intro_body": "Für automatische Positions-Einträge und den GPS-Knopf braucht die App Zugriff auf deinen Standort.\n\nTippe auf „Standort erlauben“ – im nächsten Dialog die Freigabe bestätigen. Du kannst jederzeit manuell unter „Position“ eintragen.",
|
||||||
|
"gps_live_intro_allow": "Standort erlauben",
|
||||||
|
"gps_live_intro_later": "Später",
|
||||||
|
"gps_enable_in_settings_hint": "Standortzugriff ist blockiert. In den Browser- oder Geräteeinstellungen (Website / App → Standort) kannst du die Freigabe nachträglich erlauben.",
|
||||||
"weather_btn": "OpenWeatherMap Wetter abrufen",
|
"weather_btn": "OpenWeatherMap Wetter abrufen",
|
||||||
|
"weather_offline": "OpenWeatherMap erfordert eine Internetverbindung. Du bist derzeit offline.",
|
||||||
"event_wind_pressure": "Luftdruck (hPa)",
|
"event_wind_pressure": "Luftdruck (hPa)",
|
||||||
"event_heel": "Krängung (°)",
|
"event_heel": "Krängung (°)",
|
||||||
"event_sails": "Segelführung / Motor",
|
"event_sails": "Segelführung / Motor",
|
||||||
@@ -371,10 +430,24 @@
|
|||||||
"share_csv": "CSV teilen",
|
"share_csv": "CSV teilen",
|
||||||
"export_pdf": "PDF herunterladen",
|
"export_pdf": "PDF herunterladen",
|
||||||
"exporting_pdf": "PDF wird generiert...",
|
"exporting_pdf": "PDF wird generiert...",
|
||||||
"photos_title": "Foto-Anhänge (E2E-verschlüsselt)",
|
"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",
|
||||||
"photo_caption_label": "Foto-Beschreibung / Label (Optional)",
|
"photo_caption_label": "Foto-Beschreibung / Label (Optional)",
|
||||||
"photo_caption_placeholder": "z.B. Segel setzen nahe Hafeneinfahrt",
|
"photo_caption_placeholder": "z.B. Segel setzen nahe Hafeneinfahrt",
|
||||||
"photo_btn": "Foto aufnehmen / Hochladen",
|
"photo_btn": "Foto aufnehmen / Hochladen",
|
||||||
|
"photo_camera_btn": "Foto aufnehmen",
|
||||||
|
"photo_gallery_btn": "Aus Galerie wählen",
|
||||||
"photo_processing": "Wird verarbeitet...",
|
"photo_processing": "Wird verarbeitet...",
|
||||||
"no_photos": "Noch keine Fotos an diesen Reisetag angehängt.",
|
"no_photos": "Noch keine Fotos an diesen Reisetag angehängt.",
|
||||||
"photo_delete_confirm": "Bist du sicher, dass du dieses Foto unwiderruflich löschen möchtest?",
|
"photo_delete_confirm": "Bist du sicher, dass du dieses Foto unwiderruflich löschen möchtest?",
|
||||||
@@ -439,8 +512,8 @@
|
|||||||
"nmea_change_engine_stop": "Motor aus",
|
"nmea_change_engine_stop": "Motor aus",
|
||||||
"nmea_change_autopilot_on": "Autopilot ein",
|
"nmea_change_autopilot_on": "Autopilot ein",
|
||||||
"nmea_change_autopilot_off": "Autopilot aus",
|
"nmea_change_autopilot_off": "Autopilot aus",
|
||||||
"nmea_change_gps_lost": "GPS-Fix verloren",
|
"nmea_change_gps_lost": "GPS-Position verloren",
|
||||||
"nmea_change_gps_regained": "GPS-Fix wiederhergestellt",
|
"nmea_change_gps_regained": "GPS-Position wiederhergestellt",
|
||||||
"nmea_change_water_temp": "Wassertemp. {{from}} → {{to}} °C",
|
"nmea_change_water_temp": "Wassertemp. {{from}} → {{to}} °C",
|
||||||
"nmea_change_departure": "Abfahrt / Fahrtbeginn",
|
"nmea_change_departure": "Abfahrt / Fahrtbeginn",
|
||||||
"nmea_change_anchor": "Ankern / Stop",
|
"nmea_change_anchor": "Ankern / Stop",
|
||||||
@@ -464,7 +537,7 @@
|
|||||||
"new_logbook_placeholder": "Name des Logbuchs oder der Yacht",
|
"new_logbook_placeholder": "Name des Logbuchs oder der Yacht",
|
||||||
"logout": "Abmelden",
|
"logout": "Abmelden",
|
||||||
"logged_in_as": "Angemeldet als {{name}}",
|
"logged_in_as": "Angemeldet als {{name}}",
|
||||||
"delete_confirm": "Bist du sicher, dass du dieses Logbuch unwiderruflich löschen möchtest? Alle lokalen Daten und Server-Kopien werden vernichtet.\n\nTipp: Erstelle vorher unter Einstellungen → Backup & Wiederherstellung eine Sicherungskopie (.daagbok.json), falls du die Daten später behalten möchtest.",
|
"delete_confirm": "Bist du sicher, dass du dieses Logbuch unwiderruflich löschen möchtest? Alle lokalen Daten und Server-Kopien werden vernichtet.\n\nTipp: Erstelle vorher unter Einstellungen → Backup & Wiederherstellung eine Sicherungskopie (.daagbok), falls du die Daten später behalten möchtest.",
|
||||||
"no_logbooks": "Keine Logbücher gefunden. Erstelle dein erstes Logbuch, um zu beginnen!",
|
"no_logbooks": "Keine Logbücher gefunden. Erstelle dein erstes Logbuch, um zu beginnen!",
|
||||||
"loading": "Logbücher werden geladen...",
|
"loading": "Logbücher werden geladen...",
|
||||||
"status_synced": "Synchronisiert",
|
"status_synced": "Synchronisiert",
|
||||||
@@ -604,6 +677,12 @@
|
|||||||
"integrations_title": "Integrationen",
|
"integrations_title": "Integrationen",
|
||||||
"owm_key": "OpenWeatherMap API-Schlüssel",
|
"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.",
|
"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_save": "Speichern",
|
||||||
"prefs_saving": "Wird gespeichert…",
|
"prefs_saving": "Wird gespeichert…",
|
||||||
"prefs_saved": "Gespeichert",
|
"prefs_saved": "Gespeichert",
|
||||||
@@ -725,6 +804,9 @@
|
|||||||
"no_key": "Kein OpenWeatherMap-API-Schlüssel verfügbar. Hinterlege einen eigenen Schlüssel im Benutzerprofil oder kontaktiere den Betreiber.",
|
"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_success": "Wetterdaten erfolgreich abgerufen!",
|
||||||
"weather_error": "Wetterdatenabruf fehlgeschlagen. Überprüfe den API-Schlüssel und die Verbindung.",
|
"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.",
|
"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.",
|
"gps_error": "Bitte gib einen Ort an oder ermittle die GPS-Koordinaten.",
|
||||||
"share_title": "Logbuch teilen (Schreibgeschützt)",
|
"share_title": "Logbuch teilen (Schreibgeschützt)",
|
||||||
@@ -743,7 +825,7 @@
|
|||||||
"delete_account_confirm_yes": "Ja, Konto und alle Daten löschen",
|
"delete_account_confirm_yes": "Ja, Konto und alle Daten löschen",
|
||||||
"delete_account_confirm_no": "Abbrechen",
|
"delete_account_confirm_no": "Abbrechen",
|
||||||
"delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuche es erneut.",
|
"delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuche es erneut.",
|
||||||
"delete_backup_hint": "Tipp: Erstelle vor dem Löschen Backups deiner Logbücher (.daagbok.json) in den Einstellungen jedes Logbuchs.",
|
"delete_backup_hint": "Tipp: Erstelle vor dem Löschen Backups deiner Logbücher (.daagbok) in den Einstellungen jedes Logbuchs.",
|
||||||
"deleting_account": "Konto wird gelöscht…",
|
"deleting_account": "Konto wird gelöscht…",
|
||||||
"invite_push_prompt_title": "Push-Benachrichtigungen aktivieren?",
|
"invite_push_prompt_title": "Push-Benachrichtigungen aktivieren?",
|
||||||
"invite_push_prompt_message": "Sobald eingeladene Crewmitglieder Änderungen synchronisieren, kannst du per Push informiert werden. Es werden keine Logbuch-Inhalte im Klartext gesendet.",
|
"invite_push_prompt_message": "Sobald eingeladene Crewmitglieder Änderungen synchronisieren, kannst du per Push informiert werden. Es werden keine Logbuch-Inhalte im Klartext gesendet.",
|
||||||
@@ -752,9 +834,9 @@
|
|||||||
"invite_push_prompt_later": "Später",
|
"invite_push_prompt_later": "Später",
|
||||||
"invite_push_prompt_success": "Push-Benachrichtigungen sind auf diesem Gerät aktiv.",
|
"invite_push_prompt_success": "Push-Benachrichtigungen sind auf diesem Gerät aktiv.",
|
||||||
"backup_title": "Backup & Wiederherstellung",
|
"backup_title": "Backup & Wiederherstellung",
|
||||||
"backup_desc": "Vollständiges verschlüsseltes Backup dieses Logbuchs (Einträge, Fotos, GPS-Tracks, Crew, Schiff). Mit Backup-Passphrase geschützt — für Restore auf diesem oder einem neuen Account.",
|
"backup_desc": "Vollständiges verschlüsseltes Backup dieses Logbuchs (Einträge, Fotos, Sprachnotizen, GPS-Tracks, Crew, Schiff). Mit Backup-Passphrase geschützt — für Restore auf diesem oder einem neuen Account.",
|
||||||
"backup_export_title": "Backup erstellen",
|
"backup_export_title": "Backup erstellen",
|
||||||
"backup_export_desc": "Lädt alle lokalen Daten als .daagbok.json herunter. Bewahre Datei und Passphrase getrennt und sicher auf.",
|
"backup_export_desc": "Lädt alle lokalen Daten als komprimierte .daagbok-Datei herunter. Bewahre Datei und Passphrase getrennt und sicher auf.",
|
||||||
"backup_restore_title": "Backup wiederherstellen",
|
"backup_restore_title": "Backup wiederherstellen",
|
||||||
"backup_restore_desc": "Stellt ein Backup in deinem aktuellen Account wieder her — auch nach Registrierung eines neuen Accounts.",
|
"backup_restore_desc": "Stellt ein Backup in deinem aktuellen Account wieder her — auch nach Registrierung eines neuen Accounts.",
|
||||||
"backup_passphrase": "Backup-Passphrase",
|
"backup_passphrase": "Backup-Passphrase",
|
||||||
@@ -766,7 +848,13 @@
|
|||||||
"backup_export_btn": "Backup herunterladen",
|
"backup_export_btn": "Backup herunterladen",
|
||||||
"backup_exporting": "Backup wird erstellt…",
|
"backup_exporting": "Backup wird erstellt…",
|
||||||
"backup_export_success": "Backup erstellt ({{count}} Reisetage).",
|
"backup_export_success": "Backup erstellt ({{count}} Reisetage).",
|
||||||
"backup_file_label": "Backup-Datei (.daagbok.json)",
|
"backup_file_label": "Backup-Datei (.daagbok)",
|
||||||
|
"backup_export_progress": "Packe Dateien {{current}} / {{total}}…",
|
||||||
|
"backup_invalid_archive": "Die Datei ist kein gültiges Backup-Archiv.",
|
||||||
|
"backup_version_unsupported": "Altes Backup-Format (v1). Bitte ein aktuelles .daagbok-Backup verwenden.",
|
||||||
|
"backup_import_size_confirm": "Dieses Backup ist etwa {{size}} groß. Wiederherstellung kann auf dem Gerät länger dauern und viel Speicher belegen. Fortfahren?",
|
||||||
|
"backup_stat_voice": "{{count}} Sprachnotizen",
|
||||||
|
"backup_stat_size": "Unkomprimiert ca. {{size}}",
|
||||||
"backup_preview_btn": "Inhalt prüfen",
|
"backup_preview_btn": "Inhalt prüfen",
|
||||||
"backup_previewing": "Prüfe…",
|
"backup_previewing": "Prüfe…",
|
||||||
"backup_restore_btn": "Wiederherstellen",
|
"backup_restore_btn": "Wiederherstellen",
|
||||||
|
|||||||
+110
-22
@@ -6,6 +6,10 @@
|
|||||||
"beta": "Beta",
|
"beta": "Beta",
|
||||||
"beta_hint": "Beta release — features may still change"
|
"beta_hint": "Beta release — features may still change"
|
||||||
},
|
},
|
||||||
|
"footer": {
|
||||||
|
"kofi_label": "Ko-fi",
|
||||||
|
"kofi_title": "Support the project, development, and running costs on Ko-fi"
|
||||||
|
},
|
||||||
"languages": {
|
"languages": {
|
||||||
"de": "Deutsch",
|
"de": "Deutsch",
|
||||||
"en": "English",
|
"en": "English",
|
||||||
@@ -39,7 +43,8 @@
|
|||||||
"deviation": "Deviation Table",
|
"deviation": "Deviation Table",
|
||||||
"logs": "Logbook Entries",
|
"logs": "Logbook Entries",
|
||||||
"stats": "Statistics",
|
"stats": "Statistics",
|
||||||
"settings": "Settings"
|
"settings": "Settings",
|
||||||
|
"admin": "Admin"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"welcome": "Welcome to Kapteins Daagbok",
|
"welcome": "Welcome to Kapteins Daagbok",
|
||||||
@@ -86,7 +91,15 @@
|
|||||||
"use_localhost_link": "Switch to localhost",
|
"use_localhost_link": "Switch to localhost",
|
||||||
"error_passkey_cancelled": "Passkey sign-in was cancelled or timed out. Please try again.",
|
"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_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": {
|
"pwa": {
|
||||||
"title": "Install app",
|
"title": "Install app",
|
||||||
@@ -173,6 +186,9 @@
|
|||||||
"departure": "Departure Port (von)",
|
"departure": "Departure Port (von)",
|
||||||
"destination": "Destination Port (nach)",
|
"destination": "Destination Port (nach)",
|
||||||
"route": "Route / Journey",
|
"route": "Route / Journey",
|
||||||
|
"tanks": "Tanks",
|
||||||
|
"customize_columns": "Customize columns",
|
||||||
|
"column_selector_title": "Columns to Show",
|
||||||
"freshwater": "Freshwater (Liters)",
|
"freshwater": "Freshwater (Liters)",
|
||||||
"fuel": "Fuel (Liters)",
|
"fuel": "Fuel (Liters)",
|
||||||
"greywater": "Greywater (Liters)",
|
"greywater": "Greywater (Liters)",
|
||||||
@@ -245,13 +261,13 @@
|
|||||||
"live_sails_confirm": "Log entry",
|
"live_sails_confirm": "Log entry",
|
||||||
"live_sails_confirm_count": "Log entry ({{count}})",
|
"live_sails_confirm_count": "Log entry ({{count}})",
|
||||||
"live_sails": "Sails: {{sails}}",
|
"live_sails": "Sails: {{sails}}",
|
||||||
"live_fix": "Fix",
|
"live_position": "Position",
|
||||||
"live_fix_coords": "Fix {{lat}}, {{lng}}",
|
"live_position_coords": "Position {{lat}}, {{lng}}",
|
||||||
"live_fix_manual_hint": "GPS unavailable. Enter latitude and longitude manually, or try again with the GPS button.",
|
"live_position_manual_hint": "GPS unavailable. Enter latitude and longitude manually, or try again with the GPS button.",
|
||||||
"live_fix_gps_loading": "Getting GPS position…",
|
"live_position_gps_loading": "Getting GPS position…",
|
||||||
"live_fix_invalid": "Please enter valid coordinates (latitude −90…90, longitude −180…180).",
|
"live_position_invalid": "Please enter valid coordinates (latitude −90…90, longitude −180…180).",
|
||||||
"live_fix_lat_placeholder": "Latitude (Lat)",
|
"live_position_lat_placeholder": "Latitude (Lat)",
|
||||||
"live_fix_lng_placeholder": "Longitude (Lng)",
|
"live_position_lng_placeholder": "Longitude (Lng)",
|
||||||
"live_photo_btn": "Photo (camera)",
|
"live_photo_btn": "Photo (camera)",
|
||||||
"live_photo_capture_btn": "Capture",
|
"live_photo_capture_btn": "Capture",
|
||||||
"live_photo_save_btn": "Save",
|
"live_photo_save_btn": "Save",
|
||||||
@@ -262,21 +278,43 @@
|
|||||||
"live_photo_camera_starting": "Starting camera…",
|
"live_photo_camera_starting": "Starting camera…",
|
||||||
"live_photo_camera_denied": "Camera access denied or unavailable.",
|
"live_photo_camera_denied": "Camera access denied or unavailable.",
|
||||||
"live_photo_camera_unavailable": "Camera is not supported in this browser.",
|
"live_photo_camera_unavailable": "Camera is not supported in this browser.",
|
||||||
|
"live_photo_no_camera": "No camera is available on this device.",
|
||||||
"live_photo_error": "Could not save photo.",
|
"live_photo_error": "Could not save photo.",
|
||||||
"live_photo_entry": "Photo: {{caption}}",
|
"live_photo_entry": "Photo: {{caption}}",
|
||||||
"live_photo_entry_plain": "Photo captured",
|
"live_photo_entry_plain": "Photo captured",
|
||||||
"live_undo_photo_hint": "Photo saved",
|
"live_undo_photo_hint": "Photo saved",
|
||||||
|
"live_voice_btn": "Voice memo",
|
||||||
|
"live_voice_hint": "Record a short voice memo (max. 60 seconds).",
|
||||||
|
"live_voice_record": "Start recording",
|
||||||
|
"live_voice_stop": "Stop recording",
|
||||||
|
"live_voice_recording": "Recording {{time}}",
|
||||||
|
"live_voice_save": "Save",
|
||||||
|
"live_voice_saving": "Saving…",
|
||||||
|
"live_voice_retake": "Record again",
|
||||||
|
"live_voice_mic_denied": "Microphone access denied or unavailable.",
|
||||||
|
"live_voice_record_failed": "Recording failed. Please try again.",
|
||||||
|
"live_voice_unavailable": "Voice memo unavailable",
|
||||||
|
"live_voice_too_large": "Recording is too large. Please record a shorter memo.",
|
||||||
|
"live_voice_error": "Could not save voice memo.",
|
||||||
|
"live_voice_entry": "Voice memo: {{caption}}",
|
||||||
|
"live_voice_entry_plain": "Voice memo",
|
||||||
|
"live_voice_caption_label": "Caption (optional)",
|
||||||
|
"live_voice_caption_placeholder": "e.g. radio call with harbour master",
|
||||||
|
"live_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_btn": "Comment",
|
||||||
"live_comment_placeholder": "Enter text…",
|
"live_comment_placeholder": "Enter text…",
|
||||||
"live_comment_confirm": "Log entry",
|
"live_comment_confirm": "Log entry",
|
||||||
"live_gps_error": "Could not determine GPS position.",
|
"live_gps_error": "Could not determine GPS position.",
|
||||||
"live_gps_start_hint": "Always start your day's voyage with a position fix.",
|
"live_gps_start_hint": "Always start your day's voyage with a position.",
|
||||||
"live_event_generic": "Event",
|
"live_event_generic": "Event",
|
||||||
"live_weather_btn": "Weather",
|
"live_weather_btn": "Weather",
|
||||||
"live_weather_owm_btn": "Fetch OpenWeatherMap weather",
|
"live_weather_owm_btn": "Fetch OpenWeatherMap weather",
|
||||||
"live_weather_owm_loading": "Loading weather…",
|
"live_weather_owm_loading": "Loading weather…",
|
||||||
"live_weather_fix_required": "Log a GPS fix first (Fix button) to fetch OpenWeatherMap weather. The position must be at most 6 hours old.",
|
"live_weather_position_required": "Log a position first (Position button) to fetch OpenWeatherMap weather. The position must be at most 6 hours old.",
|
||||||
"live_weather_fix_stale": "The last GPS fix is older than 6 hours. Log a new fix before fetching weather.",
|
"live_weather_position_stale": "The last position is older than 6 hours. Log a new position before fetching weather.",
|
||||||
"live_wind_btn": "Wind",
|
"live_wind_btn": "Wind",
|
||||||
"live_temp_btn": "Temp °C",
|
"live_temp_btn": "Temp °C",
|
||||||
"live_pressure_btn": "Pressure",
|
"live_pressure_btn": "Pressure",
|
||||||
@@ -284,8 +322,8 @@
|
|||||||
"live_sea_state_btn": "Sea state",
|
"live_sea_state_btn": "Sea state",
|
||||||
"live_visibility_btn": "Visibility",
|
"live_visibility_btn": "Visibility",
|
||||||
"live_course_btn": "Course",
|
"live_course_btn": "Course",
|
||||||
"live_fuel_btn": "Fuel",
|
"live_fuel_btn": "+ Fuel",
|
||||||
"live_water_btn": "Water",
|
"live_water_btn": "+ Water",
|
||||||
"live_wind_entry": "Wind {{value}}",
|
"live_wind_entry": "Wind {{value}}",
|
||||||
"live_temp_entry": "Temperature {{temp}} °C",
|
"live_temp_entry": "Temperature {{temp}} °C",
|
||||||
"live_pressure_entry": "Pressure {{value}} hPa",
|
"live_pressure_entry": "Pressure {{value}} hPa",
|
||||||
@@ -298,6 +336,7 @@
|
|||||||
"live_auto_position": "Auto position",
|
"live_auto_position": "Auto position",
|
||||||
"live_undo_hint": "Entry saved",
|
"live_undo_hint": "Entry saved",
|
||||||
"live_undo_btn": "Undo",
|
"live_undo_btn": "Undo",
|
||||||
|
"live_cancel": "Cancel",
|
||||||
"live_pressure_placeholder": "e.g. 1013",
|
"live_pressure_placeholder": "e.g. 1013",
|
||||||
"live_temp_placeholder": "e.g. 18",
|
"live_temp_placeholder": "e.g. 18",
|
||||||
"live_precip_placeholder": "e.g. light rain",
|
"live_precip_placeholder": "e.g. light rain",
|
||||||
@@ -320,6 +359,7 @@
|
|||||||
"carry_over_tanks_yes": "Carry over",
|
"carry_over_tanks_yes": "Carry over",
|
||||||
"carry_over_tanks_no": "Start at 0",
|
"carry_over_tanks_no": "Start at 0",
|
||||||
"event_title": "Chronological Event Logbook",
|
"event_title": "Chronological Event Logbook",
|
||||||
|
"event_creator": "Entered by",
|
||||||
"no_events": "No events logged for this travel day yet.",
|
"no_events": "No events logged for this travel day yet.",
|
||||||
"event_time": "Time",
|
"event_time": "Time",
|
||||||
"event_mgk": "MgK Course",
|
"event_mgk": "MgK Course",
|
||||||
@@ -357,7 +397,26 @@
|
|||||||
"event_location_placeholder": "e.g. Kiel",
|
"event_location_placeholder": "e.g. Kiel",
|
||||||
"event_remarks": "Remarks / Events",
|
"event_remarks": "Remarks / Events",
|
||||||
"gps_btn": "Get GPS Location",
|
"gps_btn": "Get GPS Location",
|
||||||
|
"gps_permission_denied": "Location access was denied. Allow it in your browser or device settings and try again.",
|
||||||
|
"gps_timeout": "GPS timed out. Try again outdoors with a clear view of the sky.",
|
||||||
|
"gps_position_unavailable": "No GPS signal available. Wait and retry, or enter coordinates manually.",
|
||||||
|
"gps_unavailable": "GPS is not supported by this browser or device.",
|
||||||
|
"gps_failed": "Could not determine GPS position.",
|
||||||
|
"gps_fallback_no_location": "GPS failed. Enter a place under Location / harbour, departure, or destination, or type coordinates manually.",
|
||||||
|
"gps_fallback_success": "Coordinates for \"{{location}}\" resolved from place name (not GPS).",
|
||||||
|
"gps_fallback_failed": "GPS and place-name lookup both failed. Please enter coordinates manually.",
|
||||||
|
"gps_quality_excellent": "Strong GPS reception (±{{accuracy}} m)",
|
||||||
|
"gps_quality_good": "Good GPS reception (±{{accuracy}} m)",
|
||||||
|
"gps_quality_fair": "Fair GPS reception (±{{accuracy}} m) — move outdoors for a better fix.",
|
||||||
|
"gps_quality_poor": "Weak GPS reception (±{{accuracy}} m) — likely few satellites. Retry outdoors or verify the position.",
|
||||||
|
"gps_quality_unknown": "GPS position applied (accuracy not reported by device).",
|
||||||
|
"gps_live_intro_title": "Location for Live Log",
|
||||||
|
"gps_live_intro_body": "The app needs your location for automatic position entries and the GPS button.\n\nTap “Allow location” and confirm in the next dialog. You can always enter a position manually via “Position”.",
|
||||||
|
"gps_live_intro_allow": "Allow location",
|
||||||
|
"gps_live_intro_later": "Later",
|
||||||
|
"gps_enable_in_settings_hint": "Location access is blocked. You can allow it later in your browser or device settings (site / app → Location).",
|
||||||
"weather_btn": "Fetch OpenWeatherMap Weather",
|
"weather_btn": "Fetch OpenWeatherMap Weather",
|
||||||
|
"weather_offline": "OpenWeatherMap requires an internet connection. You are currently offline.",
|
||||||
"event_wind_pressure": "Barometer (hPa)",
|
"event_wind_pressure": "Barometer (hPa)",
|
||||||
"event_heel": "Heel Angle (°)",
|
"event_heel": "Heel Angle (°)",
|
||||||
"event_sails": "Sails / Motor Status",
|
"event_sails": "Sails / Motor Status",
|
||||||
@@ -371,10 +430,24 @@
|
|||||||
"share_csv": "Share CSV",
|
"share_csv": "Share CSV",
|
||||||
"export_pdf": "Download PDF",
|
"export_pdf": "Download PDF",
|
||||||
"exporting_pdf": "Generating PDF...",
|
"exporting_pdf": "Generating PDF...",
|
||||||
"photos_title": "Photo Attachments (E2E Encrypted)",
|
"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",
|
||||||
"photo_caption_label": "Photo Caption / Label (Optional)",
|
"photo_caption_label": "Photo Caption / Label (Optional)",
|
||||||
"photo_caption_placeholder": "e.g. Setting sails near harbor entrance",
|
"photo_caption_placeholder": "e.g. Setting sails near harbor entrance",
|
||||||
"photo_btn": "Take Photo / Upload",
|
"photo_btn": "Take Photo / Upload",
|
||||||
|
"photo_camera_btn": "Take Photo",
|
||||||
|
"photo_gallery_btn": "Choose from Gallery",
|
||||||
"photo_processing": "Processing...",
|
"photo_processing": "Processing...",
|
||||||
"no_photos": "No photos attached to this journal entry yet.",
|
"no_photos": "No photos attached to this journal entry yet.",
|
||||||
"photo_delete_confirm": "Are you sure you want to permanently delete this photo?",
|
"photo_delete_confirm": "Are you sure you want to permanently delete this photo?",
|
||||||
@@ -439,8 +512,8 @@
|
|||||||
"nmea_change_engine_stop": "Engine off",
|
"nmea_change_engine_stop": "Engine off",
|
||||||
"nmea_change_autopilot_on": "Autopilot on",
|
"nmea_change_autopilot_on": "Autopilot on",
|
||||||
"nmea_change_autopilot_off": "Autopilot off",
|
"nmea_change_autopilot_off": "Autopilot off",
|
||||||
"nmea_change_gps_lost": "GPS fix lost",
|
"nmea_change_gps_lost": "GPS position lost",
|
||||||
"nmea_change_gps_regained": "GPS fix restored",
|
"nmea_change_gps_regained": "GPS position restored",
|
||||||
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
|
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
|
||||||
"nmea_change_departure": "Departure / underway",
|
"nmea_change_departure": "Departure / underway",
|
||||||
"nmea_change_anchor": "Anchored / stop",
|
"nmea_change_anchor": "Anchored / stop",
|
||||||
@@ -464,7 +537,7 @@
|
|||||||
"new_logbook_placeholder": "Logbook or Yacht Name",
|
"new_logbook_placeholder": "Logbook or Yacht Name",
|
||||||
"logout": "Logout",
|
"logout": "Logout",
|
||||||
"logged_in_as": "Signed in as {{name}}",
|
"logged_in_as": "Signed in as {{name}}",
|
||||||
"delete_confirm": "Are you sure you want to permanently delete this logbook? All local data and server copies will be destroyed.\n\nTip: Create a backup first under Settings → Backup & restore (.daagbok.json) if you may need the data later.",
|
"delete_confirm": "Are you sure you want to permanently delete this logbook? All local data and server copies will be destroyed.\n\nTip: Create a backup first under Settings → Backup & restore (.daagbok) if you may need the data later.",
|
||||||
"no_logbooks": "No logbooks found. Create your first logbook to begin!",
|
"no_logbooks": "No logbooks found. Create your first logbook to begin!",
|
||||||
"loading": "Loading logbooks...",
|
"loading": "Loading logbooks...",
|
||||||
"status_synced": "Synced",
|
"status_synced": "Synced",
|
||||||
@@ -604,6 +677,12 @@
|
|||||||
"integrations_title": "Integrations",
|
"integrations_title": "Integrations",
|
||||||
"owm_key": "OpenWeatherMap API key",
|
"owm_key": "OpenWeatherMap API key",
|
||||||
"owm_help": "Optional: your own OpenWeatherMap API key. If left empty, the operator-configured server key is used.",
|
"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_save": "Save",
|
||||||
"prefs_saving": "Saving…",
|
"prefs_saving": "Saving…",
|
||||||
"prefs_saved": "Saved",
|
"prefs_saved": "Saved",
|
||||||
@@ -725,6 +804,9 @@
|
|||||||
"no_key": "No OpenWeatherMap API key available. Add your own key in your user profile or contact the operator.",
|
"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_success": "Weather details fetched successfully!",
|
||||||
"weather_error": "Failed to fetch weather. Check your API key and connection.",
|
"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}}.",
|
"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.",
|
"gps_error": "Please enter a location or fetch GPS coordinates first.",
|
||||||
"share_title": "Share Logbook (Read-Only)",
|
"share_title": "Share Logbook (Read-Only)",
|
||||||
@@ -743,7 +825,7 @@
|
|||||||
"delete_account_confirm_yes": "Yes, Delete Account and All Data",
|
"delete_account_confirm_yes": "Yes, Delete Account and All Data",
|
||||||
"delete_account_confirm_no": "Cancel",
|
"delete_account_confirm_no": "Cancel",
|
||||||
"delete_account_failed": "Failed to delete account. Please try again.",
|
"delete_account_failed": "Failed to delete account. Please try again.",
|
||||||
"delete_backup_hint": "Tip: Before deleting, create backups of your logbooks (.daagbok.json) in each logbook's settings.",
|
"delete_backup_hint": "Tip: Before deleting, create backups of your logbooks (.daagbok) in each logbook's settings.",
|
||||||
"deleting_account": "Deleting account…",
|
"deleting_account": "Deleting account…",
|
||||||
"invite_push_prompt_title": "Enable push notifications?",
|
"invite_push_prompt_title": "Enable push notifications?",
|
||||||
"invite_push_prompt_message": "When invited crew members sync changes, you can be notified via push. No logbook content is sent in plain text.",
|
"invite_push_prompt_message": "When invited crew members sync changes, you can be notified via push. No logbook content is sent in plain text.",
|
||||||
@@ -752,9 +834,9 @@
|
|||||||
"invite_push_prompt_later": "Later",
|
"invite_push_prompt_later": "Later",
|
||||||
"invite_push_prompt_success": "Push notifications are active on this device.",
|
"invite_push_prompt_success": "Push notifications are active on this device.",
|
||||||
"backup_title": "Backup & restore",
|
"backup_title": "Backup & restore",
|
||||||
"backup_desc": "Full encrypted backup of this logbook (entries, photos, GPS tracks, crew, vessel). Protected with a backup passphrase — restore on this or a new account.",
|
"backup_desc": "Full encrypted backup of this logbook (entries, photos, voice memos, GPS tracks, crew, vessel). Protected with a backup passphrase — restore on this or a new account.",
|
||||||
"backup_export_title": "Create backup",
|
"backup_export_title": "Create backup",
|
||||||
"backup_export_desc": "Downloads all local data as a .daagbok.json file. Keep the file and passphrase separate and secure.",
|
"backup_export_desc": "Downloads all local data as a compressed .daagbok archive. Keep the file and passphrase separate and secure.",
|
||||||
"backup_restore_title": "Restore backup",
|
"backup_restore_title": "Restore backup",
|
||||||
"backup_restore_desc": "Restores a backup into your current account — including after registering a new account.",
|
"backup_restore_desc": "Restores a backup into your current account — including after registering a new account.",
|
||||||
"backup_passphrase": "Backup passphrase",
|
"backup_passphrase": "Backup passphrase",
|
||||||
@@ -766,7 +848,13 @@
|
|||||||
"backup_export_btn": "Download backup",
|
"backup_export_btn": "Download backup",
|
||||||
"backup_exporting": "Creating backup…",
|
"backup_exporting": "Creating backup…",
|
||||||
"backup_export_success": "Backup created ({{count}} travel days).",
|
"backup_export_success": "Backup created ({{count}} travel days).",
|
||||||
"backup_file_label": "Backup file (.daagbok.json)",
|
"backup_file_label": "Backup file (.daagbok)",
|
||||||
|
"backup_export_progress": "Packing files {{current}} / {{total}}…",
|
||||||
|
"backup_invalid_archive": "The file is not a valid backup archive.",
|
||||||
|
"backup_version_unsupported": "Legacy backup format (v1). Please use a current .daagbok backup.",
|
||||||
|
"backup_import_size_confirm": "This backup is about {{size}} uncompressed. Restore may take longer and use significant memory. Continue?",
|
||||||
|
"backup_stat_voice": "{{count}} voice memos",
|
||||||
|
"backup_stat_size": "Approx. {{size}} uncompressed",
|
||||||
"backup_preview_btn": "Verify contents",
|
"backup_preview_btn": "Verify contents",
|
||||||
"backup_previewing": "Verifying…",
|
"backup_previewing": "Verifying…",
|
||||||
"backup_restore_btn": "Restore",
|
"backup_restore_btn": "Restore",
|
||||||
|
|||||||
+108
-20
@@ -6,6 +6,10 @@
|
|||||||
"beta": "Beta",
|
"beta": "Beta",
|
||||||
"beta_hint": "Betaversjon - funksjoner kan fortsatt endres"
|
"beta_hint": "Betaversjon - funksjoner kan fortsatt endres"
|
||||||
},
|
},
|
||||||
|
"footer": {
|
||||||
|
"kofi_label": "Ko-fi",
|
||||||
|
"kofi_title": "Støtt prosjektet, videreutvikling og driftskostnader på Ko-fi"
|
||||||
|
},
|
||||||
"languages": {
|
"languages": {
|
||||||
"de": "Deutsch",
|
"de": "Deutsch",
|
||||||
"en": "English",
|
"en": "English",
|
||||||
@@ -39,7 +43,8 @@
|
|||||||
"deviation": "Tabell over distraksjoner",
|
"deviation": "Tabell over distraksjoner",
|
||||||
"logs": "Loggbokoppføringer",
|
"logs": "Loggbokoppføringer",
|
||||||
"stats": "Statistikk",
|
"stats": "Statistikk",
|
||||||
"settings": "Innstillinger"
|
"settings": "Innstillinger",
|
||||||
|
"admin": "Admin"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"welcome": "Velkommen til Kapteins Daagbok",
|
"welcome": "Velkommen til Kapteins Daagbok",
|
||||||
@@ -86,7 +91,15 @@
|
|||||||
"use_localhost_link": "Bytt til localhost",
|
"use_localhost_link": "Bytt til localhost",
|
||||||
"error_passkey_cancelled": "Passkey-innlogging ble avbrutt eller utløp. Prøv igjen.",
|
"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_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": {
|
"pwa": {
|
||||||
"title": "Installer app",
|
"title": "Installer app",
|
||||||
@@ -173,6 +186,9 @@
|
|||||||
"departure": "Starthavn (reise fra)",
|
"departure": "Starthavn (reise fra)",
|
||||||
"destination": "Destinasjonsport (til)",
|
"destination": "Destinasjonsport (til)",
|
||||||
"route": "Reise fra/til",
|
"route": "Reise fra/til",
|
||||||
|
"tanks": "Tanker",
|
||||||
|
"customize_columns": "Tilpass kolonner",
|
||||||
|
"column_selector_title": "Kolonner å vise",
|
||||||
"freshwater": "Ferskvann (liter)",
|
"freshwater": "Ferskvann (liter)",
|
||||||
"fuel": "Drivstoff / Drivstoff (liter)",
|
"fuel": "Drivstoff / Drivstoff (liter)",
|
||||||
"greywater": "Gråvann (liter)",
|
"greywater": "Gråvann (liter)",
|
||||||
@@ -245,13 +261,13 @@
|
|||||||
"live_sails_confirm": "Loggfør",
|
"live_sails_confirm": "Loggfør",
|
||||||
"live_sails_confirm_count": "Loggfør ({{count}})",
|
"live_sails_confirm_count": "Loggfør ({{count}})",
|
||||||
"live_sails": "Seil: {{sails}}",
|
"live_sails": "Seil: {{sails}}",
|
||||||
"live_fix": "Fix",
|
"live_position": "Posisjon",
|
||||||
"live_fix_coords": "Fix {{lat}}, {{lng}}",
|
"live_position_coords": "Posisjon {{lat}}, {{lng}}",
|
||||||
"live_fix_manual_hint": "GPS ikke tilgjengelig. Skriv inn bredde- og lengdegrad manuelt, eller prøv igjen med GPS-knappen.",
|
"live_position_manual_hint": "GPS ikke tilgjengelig. Skriv inn bredde- og lengdegrad manuelt, eller prøv igjen med GPS-knappen.",
|
||||||
"live_fix_gps_loading": "Henter GPS-posisjon…",
|
"live_position_gps_loading": "Henter GPS-posisjon…",
|
||||||
"live_fix_invalid": "Skriv inn gyldige koordinater (bredde −90…90, lengde −180…180).",
|
"live_position_invalid": "Skriv inn gyldige koordinater (bredde −90…90, lengde −180…180).",
|
||||||
"live_fix_lat_placeholder": "Bredde (Lat)",
|
"live_position_lat_placeholder": "Bredde (Lat)",
|
||||||
"live_fix_lng_placeholder": "Lengde (Lng)",
|
"live_position_lng_placeholder": "Lengde (Lng)",
|
||||||
"live_photo_btn": "Foto (kamera)",
|
"live_photo_btn": "Foto (kamera)",
|
||||||
"live_photo_capture_btn": "Ta bilde",
|
"live_photo_capture_btn": "Ta bilde",
|
||||||
"live_photo_save_btn": "Lagre",
|
"live_photo_save_btn": "Lagre",
|
||||||
@@ -262,10 +278,32 @@
|
|||||||
"live_photo_camera_starting": "Starter kamera…",
|
"live_photo_camera_starting": "Starter kamera…",
|
||||||
"live_photo_camera_denied": "Kameratilgang nektet eller utilgjengelig.",
|
"live_photo_camera_denied": "Kameratilgang nektet eller utilgjengelig.",
|
||||||
"live_photo_camera_unavailable": "Kamera støttes ikke i denne nettleseren.",
|
"live_photo_camera_unavailable": "Kamera støttes ikke i denne nettleseren.",
|
||||||
|
"live_photo_no_camera": "Ingen kamera er tilgjengelig på denne enheten.",
|
||||||
"live_photo_error": "Kunne ikke lagre foto.",
|
"live_photo_error": "Kunne ikke lagre foto.",
|
||||||
"live_photo_entry": "Foto: {{caption}}",
|
"live_photo_entry": "Foto: {{caption}}",
|
||||||
"live_photo_entry_plain": "Foto tatt",
|
"live_photo_entry_plain": "Foto tatt",
|
||||||
"live_undo_photo_hint": "Foto lagret",
|
"live_undo_photo_hint": "Foto lagret",
|
||||||
|
"live_voice_btn": "Talemelding",
|
||||||
|
"live_voice_hint": "Ta opp en kort talemelding (maks. 60 sekunder).",
|
||||||
|
"live_voice_record": "Start opptak",
|
||||||
|
"live_voice_stop": "Stopp opptak",
|
||||||
|
"live_voice_recording": "Tar opp {{time}}",
|
||||||
|
"live_voice_save": "Lagre",
|
||||||
|
"live_voice_saving": "Lagrer…",
|
||||||
|
"live_voice_retake": "Ta opp på nytt",
|
||||||
|
"live_voice_mic_denied": "Mikrofontilgang nektet eller utilgjengelig.",
|
||||||
|
"live_voice_record_failed": "Opptak mislyktes. Prøv igjen.",
|
||||||
|
"live_voice_unavailable": "Talemelding utilgjengelig",
|
||||||
|
"live_voice_too_large": "Opptaket er for stort. Ta et kortere opptak.",
|
||||||
|
"live_voice_error": "Kunne ikke lagre talemelding.",
|
||||||
|
"live_voice_entry": "Talemelding: {{caption}}",
|
||||||
|
"live_voice_entry_plain": "Talemelding",
|
||||||
|
"live_voice_caption_label": "Bildetekst (valgfritt)",
|
||||||
|
"live_voice_caption_placeholder": "f.eks. radiokontakt med havnesjef",
|
||||||
|
"live_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_btn": "Kommentar",
|
||||||
"live_comment_placeholder": "Skriv inn tekst…",
|
"live_comment_placeholder": "Skriv inn tekst…",
|
||||||
"live_comment_confirm": "Loggfør",
|
"live_comment_confirm": "Loggfør",
|
||||||
@@ -275,8 +313,8 @@
|
|||||||
"live_weather_btn": "Vær",
|
"live_weather_btn": "Vær",
|
||||||
"live_weather_owm_btn": "Hent OpenWeatherMap-vær",
|
"live_weather_owm_btn": "Hent OpenWeatherMap-vær",
|
||||||
"live_weather_owm_loading": "Henter vær…",
|
"live_weather_owm_loading": "Henter vær…",
|
||||||
"live_weather_fix_required": "Logg først en GPS-fix (Fix-knapp) for å hente OpenWeatherMap-vær. Posisjonen må være maks 6 timer gammel.",
|
"live_weather_position_required": "Logg først en posisjon (Posisjon-knapp) for å hente OpenWeatherMap-vær. Posisjonen må være maks 6 timer gammel.",
|
||||||
"live_weather_fix_stale": "Siste GPS-fix er eldre enn 6 timer. Logg en ny fix før du henter vær.",
|
"live_weather_position_stale": "Siste posisjon er eldre enn 6 timer. Logg en ny posisjon før du henter vær.",
|
||||||
"live_wind_btn": "Vind",
|
"live_wind_btn": "Vind",
|
||||||
"live_temp_btn": "T °C",
|
"live_temp_btn": "T °C",
|
||||||
"live_pressure_btn": "Lufttrykk",
|
"live_pressure_btn": "Lufttrykk",
|
||||||
@@ -284,8 +322,8 @@
|
|||||||
"live_sea_state_btn": "Sjøgang",
|
"live_sea_state_btn": "Sjøgang",
|
||||||
"live_visibility_btn": "Sikt",
|
"live_visibility_btn": "Sikt",
|
||||||
"live_course_btn": "Kurs",
|
"live_course_btn": "Kurs",
|
||||||
"live_fuel_btn": "Diesel",
|
"live_fuel_btn": "+ Diesel",
|
||||||
"live_water_btn": "Vann",
|
"live_water_btn": "+ Vann",
|
||||||
"live_wind_entry": "Vind {{value}}",
|
"live_wind_entry": "Vind {{value}}",
|
||||||
"live_temp_entry": "Temperatur {{temp}} °C",
|
"live_temp_entry": "Temperatur {{temp}} °C",
|
||||||
"live_pressure_entry": "Lufttrykk {{value}} hPa",
|
"live_pressure_entry": "Lufttrykk {{value}} hPa",
|
||||||
@@ -298,6 +336,7 @@
|
|||||||
"live_auto_position": "Auto-posisjon",
|
"live_auto_position": "Auto-posisjon",
|
||||||
"live_undo_hint": "Oppføring lagret",
|
"live_undo_hint": "Oppføring lagret",
|
||||||
"live_undo_btn": "Angre",
|
"live_undo_btn": "Angre",
|
||||||
|
"live_cancel": "Avbryt",
|
||||||
"live_pressure_placeholder": "f.eks. 1013",
|
"live_pressure_placeholder": "f.eks. 1013",
|
||||||
"live_temp_placeholder": "f.eks. 18",
|
"live_temp_placeholder": "f.eks. 18",
|
||||||
"live_precip_placeholder": "f.eks. lett regn",
|
"live_precip_placeholder": "f.eks. lett regn",
|
||||||
@@ -320,6 +359,7 @@
|
|||||||
"carry_over_tanks_yes": "Ta over",
|
"carry_over_tanks_yes": "Ta over",
|
||||||
"carry_over_tanks_no": "Begynn med 0",
|
"carry_over_tanks_no": "Begynn med 0",
|
||||||
"event_title": "Kronologisk hendelseslogg",
|
"event_title": "Kronologisk hendelseslogg",
|
||||||
|
"event_creator": "Registrert av",
|
||||||
"no_events": "Ingen arrangementer lagt inn for denne reisedagen ennå.",
|
"no_events": "Ingen arrangementer lagt inn for denne reisedagen ennå.",
|
||||||
"event_time": "Tid på døgnet",
|
"event_time": "Tid på døgnet",
|
||||||
"event_mgk": "MgK-kurs",
|
"event_mgk": "MgK-kurs",
|
||||||
@@ -357,7 +397,26 @@
|
|||||||
"event_location_placeholder": "z. f.eks. Kiel",
|
"event_location_placeholder": "z. f.eks. Kiel",
|
||||||
"event_remarks": "Merknader / hendelser",
|
"event_remarks": "Merknader / hendelser",
|
||||||
"gps_btn": "Hent GPS-koordinater",
|
"gps_btn": "Hent GPS-koordinater",
|
||||||
|
"gps_permission_denied": "Tilgang til posisjon ble nektet. Tillat det i nettleser- eller enhetsinnstillinger og prøv igjen.",
|
||||||
|
"gps_timeout": "GPS fikk tidsavbrudd. Prøv igjen utendørs med fri sikt mot himmelen.",
|
||||||
|
"gps_position_unavailable": "Ingen GPS-signal tilgjengelig. Vent og prøv igjen, eller skriv inn koordinater manuelt.",
|
||||||
|
"gps_unavailable": "GPS støttes ikke av denne nettleseren eller enheten.",
|
||||||
|
"gps_failed": "GPS-posisjon kunne ikke bestemmes.",
|
||||||
|
"gps_fallback_no_location": "GPS mislyktes. Skriv inn et sted under sted/havn, avreise eller destinasjon, eller koordinater manuelt.",
|
||||||
|
"gps_fallback_success": "Koordinater for «{{location}}» funnet via stedsnavn (ikke GPS).",
|
||||||
|
"gps_fallback_failed": "GPS og stedsnavnssøk mislyktes. Skriv inn koordinater manuelt.",
|
||||||
|
"gps_quality_excellent": "Sterk GPS-mottak (±{{accuracy}} m)",
|
||||||
|
"gps_quality_good": "God GPS-mottak (±{{accuracy}} m)",
|
||||||
|
"gps_quality_fair": "Middels GPS-mottak (±{{accuracy}} m) – gå utendørs for bedre signal.",
|
||||||
|
"gps_quality_poor": "Svakt GPS-mottak (±{{accuracy}} m) – sannsynligvis få satellitter. Prøv utendørs igjen eller kontroller posisjonen.",
|
||||||
|
"gps_quality_unknown": "GPS-posisjon tatt i bruk (nøyaktighet ikke rapportert av enheten).",
|
||||||
|
"gps_live_intro_title": "Posisjon for live-logg",
|
||||||
|
"gps_live_intro_body": "Appen trenger posisjonen din for automatiske posisjonsregistreringer og GPS-knappen.\n\nTrykk «Tillat posisjon» og bekreft i neste dialog. Du kan alltid legge inn posisjon manuelt via «Posisjon».",
|
||||||
|
"gps_live_intro_allow": "Tillat posisjon",
|
||||||
|
"gps_live_intro_later": "Senere",
|
||||||
|
"gps_enable_in_settings_hint": "Posisjonstilgang er blokkert. Du kan tillate det senere i nettleser- eller enhetsinnstillinger (nettsted / app → Posisjon).",
|
||||||
"weather_btn": "OpenWeatherMap Ring opp været",
|
"weather_btn": "OpenWeatherMap Ring opp været",
|
||||||
|
"weather_offline": "OpenWeatherMap krever internettforbindelse. Du er frakoblet.",
|
||||||
"event_wind_pressure": "Lufttrykk (hPa)",
|
"event_wind_pressure": "Lufttrykk (hPa)",
|
||||||
"event_heel": "Helning (°)",
|
"event_heel": "Helning (°)",
|
||||||
"event_sails": "Seilhåndtering / motor",
|
"event_sails": "Seilhåndtering / motor",
|
||||||
@@ -371,10 +430,24 @@
|
|||||||
"share_csv": "CSV andel",
|
"share_csv": "CSV andel",
|
||||||
"export_pdf": "Last ned PDF",
|
"export_pdf": "Last ned PDF",
|
||||||
"exporting_pdf": "PDF genereres...",
|
"exporting_pdf": "PDF genereres...",
|
||||||
"photos_title": "Bildevedlegg (E2E-kryptert)",
|
"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",
|
||||||
"photo_caption_label": "Fotobeskrivelse/etikett (valgfritt)",
|
"photo_caption_label": "Fotobeskrivelse/etikett (valgfritt)",
|
||||||
"photo_caption_placeholder": "f.eks. å sette seil nær innseilingen til havnen",
|
"photo_caption_placeholder": "f.eks. å sette seil nær innseilingen til havnen",
|
||||||
"photo_btn": "Ta bilde / last opp",
|
"photo_btn": "Ta bilde / last opp",
|
||||||
|
"photo_camera_btn": "Ta bilde",
|
||||||
|
"photo_gallery_btn": "Velg fra galleri",
|
||||||
"photo_processing": "...blir behandlet...",
|
"photo_processing": "...blir behandlet...",
|
||||||
"no_photos": "Ingen bilder knyttet til denne reisedagen ennå.",
|
"no_photos": "Ingen bilder knyttet til denne reisedagen ennå.",
|
||||||
"photo_delete_confirm": "Er du sikker på at du vil slette dette bildet permanent?",
|
"photo_delete_confirm": "Er du sikker på at du vil slette dette bildet permanent?",
|
||||||
@@ -449,8 +522,8 @@
|
|||||||
"nmea_change_engine_stop": "Engine off",
|
"nmea_change_engine_stop": "Engine off",
|
||||||
"nmea_change_autopilot_on": "Autopilot on",
|
"nmea_change_autopilot_on": "Autopilot on",
|
||||||
"nmea_change_autopilot_off": "Autopilot off",
|
"nmea_change_autopilot_off": "Autopilot off",
|
||||||
"nmea_change_gps_lost": "GPS fix lost",
|
"nmea_change_gps_lost": "GPS-posisjon tapt",
|
||||||
"nmea_change_gps_regained": "GPS fix restored",
|
"nmea_change_gps_regained": "GPS-posisjon gjenopprettet",
|
||||||
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
|
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
|
||||||
"nmea_change_departure": "Departure / underway",
|
"nmea_change_departure": "Departure / underway",
|
||||||
"nmea_change_anchor": "Anchored / stop",
|
"nmea_change_anchor": "Anchored / stop",
|
||||||
@@ -464,7 +537,7 @@
|
|||||||
"new_logbook_placeholder": "Navn på loggboken eller båten",
|
"new_logbook_placeholder": "Navn på loggboken eller båten",
|
||||||
"logout": "Logg ut",
|
"logout": "Logg ut",
|
||||||
"logged_in_as": "Innlogget som {{name}}",
|
"logged_in_as": "Innlogget som {{name}}",
|
||||||
"delete_confirm": "Er du sikker på at du vil slette denne loggboken permanent? Alle lokale data og serverkopier vil bli ødelagt.\n\nTips: Lag en sikkerhetskopi (.daagbok.json) på forhånd under Innstillinger → Sikkerhetskopiering og gjenoppretting hvis du ønsker å beholde dataene senere.",
|
"delete_confirm": "Er du sikker på at du vil slette denne loggboken permanent? Alle lokale data og serverkopier vil bli ødelagt.\n\nTips: Lag en sikkerhetskopi (.daagbok) på forhånd under Innstillinger → Sikkerhetskopiering og gjenoppretting hvis du ønsker å beholde dataene senere.",
|
||||||
"no_logbooks": "Ingen loggbøker funnet. Opprett din første loggbok for å komme i gang!",
|
"no_logbooks": "Ingen loggbøker funnet. Opprett din første loggbok for å komme i gang!",
|
||||||
"loading": "Loggbøker er lastet...",
|
"loading": "Loggbøker er lastet...",
|
||||||
"status_synced": "Synkronisert",
|
"status_synced": "Synkronisert",
|
||||||
@@ -604,6 +677,12 @@
|
|||||||
"integrations_title": "Integrasjoner",
|
"integrations_title": "Integrasjoner",
|
||||||
"owm_key": "OpenWeatherMap API-nøkkel",
|
"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.",
|
"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_save": "Spar",
|
||||||
"prefs_saving": "...vil bli reddet...",
|
"prefs_saving": "...vil bli reddet...",
|
||||||
"prefs_saved": "Reddet",
|
"prefs_saved": "Reddet",
|
||||||
@@ -725,6 +804,9 @@
|
|||||||
"no_key": "Ingen OpenWeatherMap API-nøkkel tilgjengelig. Lagre din egen nøkkel i brukerprofilen, eller kontakt operatøren.",
|
"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_success": "Værdata vellykket hentet!",
|
||||||
"weather_error": "Henting av værdata mislyktes. Kontroller API-nøkkelen og tilkoblingen.",
|
"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}}.",
|
"weather_date_mismatch": "Værdata kan bare hentes ut for i dag ({{today}}). Denne loggbokoppføringen er datert {{date}}.",
|
||||||
"gps_error": "Vennligst skriv inn en posisjon eller finn GPS-koordinatene.",
|
"gps_error": "Vennligst skriv inn en posisjon eller finn GPS-koordinatene.",
|
||||||
"share_title": "Del loggbok (skrivebeskyttet)",
|
"share_title": "Del loggbok (skrivebeskyttet)",
|
||||||
@@ -743,7 +825,7 @@
|
|||||||
"delete_account_confirm_yes": "Ja, slett konto og alle data",
|
"delete_account_confirm_yes": "Ja, slett konto og alle data",
|
||||||
"delete_account_confirm_no": "Avbryt",
|
"delete_account_confirm_no": "Avbryt",
|
||||||
"delete_account_failed": "Kontoen kunne ikke slettes. Vennligst prøv igjen.",
|
"delete_account_failed": "Kontoen kunne ikke slettes. Vennligst prøv igjen.",
|
||||||
"delete_backup_hint": "Tips: Lag sikkerhetskopier av loggbøkene dine (.daagbok.json) i innstillingene for hver loggbok før du sletter dem.",
|
"delete_backup_hint": "Tips: Lag sikkerhetskopier av loggbøkene dine (.daagbok) i innstillingene for hver loggbok før du sletter dem.",
|
||||||
"deleting_account": "Kontoen vil bli slettet...",
|
"deleting_account": "Kontoen vil bli slettet...",
|
||||||
"invite_push_prompt_title": "Aktivere push-varsler?",
|
"invite_push_prompt_title": "Aktivere push-varsler?",
|
||||||
"invite_push_prompt_message": "Så snart inviterte Crew-medlemmer synkroniserer endringer, kan du bli informert via push. Ingen loggbokinnhold sendes i ren tekst.",
|
"invite_push_prompt_message": "Så snart inviterte Crew-medlemmer synkroniserer endringer, kan du bli informert via push. Ingen loggbokinnhold sendes i ren tekst.",
|
||||||
@@ -754,7 +836,7 @@
|
|||||||
"backup_title": "Sikkerhetskopiering og gjenoppretting",
|
"backup_title": "Sikkerhetskopiering og gjenoppretting",
|
||||||
"backup_desc": "Fullstendig kryptert sikkerhetskopi av denne loggboken (oppføringer, bilder, GPS-spor, crew, skip). Beskyttet med sikkerhetskopieringspassord - for gjenoppretting til denne eller en ny konto.",
|
"backup_desc": "Fullstendig kryptert sikkerhetskopi av denne loggboken (oppføringer, bilder, GPS-spor, crew, skip). Beskyttet med sikkerhetskopieringspassord - for gjenoppretting til denne eller en ny konto.",
|
||||||
"backup_export_title": "Opprett sikkerhetskopi",
|
"backup_export_title": "Opprett sikkerhetskopi",
|
||||||
"backup_export_desc": "Laster ned alle lokale data som .daagbok.json. Hold filen og passordfrasen adskilt og sikker.",
|
"backup_export_desc": "Laster ned alle lokale data som et komprimert .daagbok-arkiv. Hold filen og passordfrasen adskilt og sikker.",
|
||||||
"backup_restore_title": "Gjenopprett sikkerhetskopi",
|
"backup_restore_title": "Gjenopprett sikkerhetskopi",
|
||||||
"backup_restore_desc": "Gjenoppretter en sikkerhetskopi til din nåværende konto - selv etter at du har registrert en ny konto.",
|
"backup_restore_desc": "Gjenoppretter en sikkerhetskopi til din nåværende konto - selv etter at du har registrert en ny konto.",
|
||||||
"backup_passphrase": "Passord for sikkerhetskopiering",
|
"backup_passphrase": "Passord for sikkerhetskopiering",
|
||||||
@@ -766,7 +848,13 @@
|
|||||||
"backup_export_btn": "Last ned sikkerhetskopi",
|
"backup_export_btn": "Last ned sikkerhetskopi",
|
||||||
"backup_exporting": "Sikkerhetskopien er opprettet...",
|
"backup_exporting": "Sikkerhetskopien er opprettet...",
|
||||||
"backup_export_success": "Sikkerhetskopi opprettet ({{count}} reisedager).",
|
"backup_export_success": "Sikkerhetskopi opprettet ({{count}} reisedager).",
|
||||||
"backup_file_label": "Sikkerhetskopifil (.daagbok.json)",
|
"backup_file_label": "Sikkerhetskopifil (.daagbok)",
|
||||||
|
"backup_export_progress": "Pakker filer {{current}} / {{total}}…",
|
||||||
|
"backup_invalid_archive": "Filen er ikke et gyldig backup-arkiv.",
|
||||||
|
"backup_version_unsupported": "Gammelt backup-format (v1). Bruk en aktuell .daagbok-sikkerhetskopi.",
|
||||||
|
"backup_import_size_confirm": "Denne sikkerhetskopien er ca. {{size}} ukomprimert. Gjenoppretting kan ta lengre tid. Fortsette?",
|
||||||
|
"backup_stat_voice": "{{count}} talemeldinger",
|
||||||
|
"backup_stat_size": "Ca. {{size}} ukomprimert",
|
||||||
"backup_preview_btn": "Sjekk innhold",
|
"backup_preview_btn": "Sjekk innhold",
|
||||||
"backup_previewing": "Sjekk...",
|
"backup_previewing": "Sjekk...",
|
||||||
"backup_restore_btn": "Gjenopprett",
|
"backup_restore_btn": "Gjenopprett",
|
||||||
|
|||||||
+108
-20
@@ -6,6 +6,10 @@
|
|||||||
"beta": "Beta",
|
"beta": "Beta",
|
||||||
"beta_hint": "Betaversion - funktioner kan fortfarande ändras"
|
"beta_hint": "Betaversion - funktioner kan fortfarande ändras"
|
||||||
},
|
},
|
||||||
|
"footer": {
|
||||||
|
"kofi_label": "Ko-fi",
|
||||||
|
"kofi_title": "Stöd projektet, vidareutveckling och driftskostnader på Ko-fi"
|
||||||
|
},
|
||||||
"languages": {
|
"languages": {
|
||||||
"de": "Deutsch",
|
"de": "Deutsch",
|
||||||
"en": "English",
|
"en": "English",
|
||||||
@@ -39,7 +43,8 @@
|
|||||||
"deviation": "Distraktionsbord",
|
"deviation": "Distraktionsbord",
|
||||||
"logs": "Loggboksanteckningar",
|
"logs": "Loggboksanteckningar",
|
||||||
"stats": "Statistik",
|
"stats": "Statistik",
|
||||||
"settings": "Inställningar"
|
"settings": "Inställningar",
|
||||||
|
"admin": "Admin"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"welcome": "Välkommen till Kapteins Daagbok",
|
"welcome": "Välkommen till Kapteins Daagbok",
|
||||||
@@ -86,7 +91,15 @@
|
|||||||
"use_localhost_link": "Byt till localhost",
|
"use_localhost_link": "Byt till localhost",
|
||||||
"error_passkey_cancelled": "Passkey-inloggning avbröts eller gick ut. Försök igen.",
|
"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_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": {
|
"pwa": {
|
||||||
"title": "Installera app",
|
"title": "Installera app",
|
||||||
@@ -173,6 +186,9 @@
|
|||||||
"departure": "Starthamn (resa från)",
|
"departure": "Starthamn (resa från)",
|
||||||
"destination": "Destinationsport (till)",
|
"destination": "Destinationsport (till)",
|
||||||
"route": "Resa från/till",
|
"route": "Resa från/till",
|
||||||
|
"tanks": "Tankar",
|
||||||
|
"customize_columns": "Anpassa kolumner",
|
||||||
|
"column_selector_title": "Kolumner att visa",
|
||||||
"freshwater": "Färskvatten (liter)",
|
"freshwater": "Färskvatten (liter)",
|
||||||
"fuel": "Treibstoff / Bränsle (liter)",
|
"fuel": "Treibstoff / Bränsle (liter)",
|
||||||
"greywater": "Gråvatten (liter)",
|
"greywater": "Gråvatten (liter)",
|
||||||
@@ -245,13 +261,13 @@
|
|||||||
"live_sails_confirm": "Logga",
|
"live_sails_confirm": "Logga",
|
||||||
"live_sails_confirm_count": "Logga ({{count}})",
|
"live_sails_confirm_count": "Logga ({{count}})",
|
||||||
"live_sails": "Segel: {{sails}}",
|
"live_sails": "Segel: {{sails}}",
|
||||||
"live_fix": "Fix",
|
"live_position": "Position",
|
||||||
"live_fix_coords": "Fix {{lat}}, {{lng}}",
|
"live_position_coords": "Position {{lat}}, {{lng}}",
|
||||||
"live_fix_manual_hint": "GPS ej tillgänglig. Ange latitud och longitud manuellt, eller försök igen med GPS-knappen.",
|
"live_position_manual_hint": "GPS ej tillgänglig. Ange latitud och longitud manuellt, eller försök igen med GPS-knappen.",
|
||||||
"live_fix_gps_loading": "Hämtar GPS-position…",
|
"live_position_gps_loading": "Hämtar GPS-position…",
|
||||||
"live_fix_invalid": "Ange giltiga koordinater (latitud −90…90, longitud −180…180).",
|
"live_position_invalid": "Ange giltiga koordinater (latitud −90…90, longitud −180…180).",
|
||||||
"live_fix_lat_placeholder": "Latitud (Lat)",
|
"live_position_lat_placeholder": "Latitud (Lat)",
|
||||||
"live_fix_lng_placeholder": "Longitud (Lng)",
|
"live_position_lng_placeholder": "Longitud (Lng)",
|
||||||
"live_photo_btn": "Foto (kamera)",
|
"live_photo_btn": "Foto (kamera)",
|
||||||
"live_photo_capture_btn": "Ta foto",
|
"live_photo_capture_btn": "Ta foto",
|
||||||
"live_photo_save_btn": "Spara",
|
"live_photo_save_btn": "Spara",
|
||||||
@@ -262,10 +278,32 @@
|
|||||||
"live_photo_camera_starting": "Startar kamera…",
|
"live_photo_camera_starting": "Startar kamera…",
|
||||||
"live_photo_camera_denied": "Kameraåtkomst nekad eller ej tillgänglig.",
|
"live_photo_camera_denied": "Kameraåtkomst nekad eller ej tillgänglig.",
|
||||||
"live_photo_camera_unavailable": "Kameran stöds inte i den här webbläsaren.",
|
"live_photo_camera_unavailable": "Kameran stöds inte i den här webbläsaren.",
|
||||||
|
"live_photo_no_camera": "Ingen kamera finns på den här enheten.",
|
||||||
"live_photo_error": "Foto kunde inte sparas.",
|
"live_photo_error": "Foto kunde inte sparas.",
|
||||||
"live_photo_entry": "Foto: {{caption}}",
|
"live_photo_entry": "Foto: {{caption}}",
|
||||||
"live_photo_entry_plain": "Foto taget",
|
"live_photo_entry_plain": "Foto taget",
|
||||||
"live_undo_photo_hint": "Foto sparat",
|
"live_undo_photo_hint": "Foto sparat",
|
||||||
|
"live_voice_btn": "Röstanteckning",
|
||||||
|
"live_voice_hint": "Spela in en kort röstanteckning (max 60 sekunder).",
|
||||||
|
"live_voice_record": "Starta inspelning",
|
||||||
|
"live_voice_stop": "Stoppa inspelning",
|
||||||
|
"live_voice_recording": "Spelar in {{time}}",
|
||||||
|
"live_voice_save": "Spara",
|
||||||
|
"live_voice_saving": "Sparar…",
|
||||||
|
"live_voice_retake": "Spela in igen",
|
||||||
|
"live_voice_mic_denied": "Mikrofonåtkomst nekad eller ej tillgänglig.",
|
||||||
|
"live_voice_record_failed": "Inspelning misslyckades. Försök igen.",
|
||||||
|
"live_voice_unavailable": "Röstanteckning ej tillgänglig",
|
||||||
|
"live_voice_too_large": "Inspelningen är för stor. Spela in kortare.",
|
||||||
|
"live_voice_error": "Kunde inte spara röstanteckning.",
|
||||||
|
"live_voice_entry": "Röstanteckning: {{caption}}",
|
||||||
|
"live_voice_entry_plain": "Röstanteckning",
|
||||||
|
"live_voice_caption_label": "Bildtext (valfritt)",
|
||||||
|
"live_voice_caption_placeholder": "t.ex. radiokontakt med hamnmästare",
|
||||||
|
"live_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_btn": "Kommentar",
|
||||||
"live_comment_placeholder": "Ange text…",
|
"live_comment_placeholder": "Ange text…",
|
||||||
"live_comment_confirm": "Logga",
|
"live_comment_confirm": "Logga",
|
||||||
@@ -275,8 +313,8 @@
|
|||||||
"live_weather_btn": "Väder",
|
"live_weather_btn": "Väder",
|
||||||
"live_weather_owm_btn": "Hämta OpenWeatherMap-väder",
|
"live_weather_owm_btn": "Hämta OpenWeatherMap-väder",
|
||||||
"live_weather_owm_loading": "Hämtar väder…",
|
"live_weather_owm_loading": "Hämtar väder…",
|
||||||
"live_weather_fix_required": "Logga först en GPS-fix (Fix-knappen) för att hämta OpenWeatherMap-väder. Positionen får högst vara 6 timmar gammal.",
|
"live_weather_position_required": "Logga först en position (Position-knappen) för att hämta OpenWeatherMap-väder. Positionen får högst vara 6 timmar gammal.",
|
||||||
"live_weather_fix_stale": "Senaste GPS-fixen är äldre än 6 timmar. Logga en ny fix innan du hämtar väder.",
|
"live_weather_position_stale": "Senaste positionen är äldre än 6 timmar. Logga en ny position innan du hämtar väder.",
|
||||||
"live_wind_btn": "Vind",
|
"live_wind_btn": "Vind",
|
||||||
"live_temp_btn": "T °C",
|
"live_temp_btn": "T °C",
|
||||||
"live_pressure_btn": "Lufttryck",
|
"live_pressure_btn": "Lufttryck",
|
||||||
@@ -284,8 +322,8 @@
|
|||||||
"live_sea_state_btn": "Sjögang",
|
"live_sea_state_btn": "Sjögang",
|
||||||
"live_visibility_btn": "Sikt",
|
"live_visibility_btn": "Sikt",
|
||||||
"live_course_btn": "Kurs",
|
"live_course_btn": "Kurs",
|
||||||
"live_fuel_btn": "Diesel",
|
"live_fuel_btn": "+ Diesel",
|
||||||
"live_water_btn": "Vatten",
|
"live_water_btn": "+ Vatten",
|
||||||
"live_wind_entry": "Vind {{value}}",
|
"live_wind_entry": "Vind {{value}}",
|
||||||
"live_temp_entry": "Temperatur {{temp}} °C",
|
"live_temp_entry": "Temperatur {{temp}} °C",
|
||||||
"live_pressure_entry": "Lufttryck {{value}} hPa",
|
"live_pressure_entry": "Lufttryck {{value}} hPa",
|
||||||
@@ -298,6 +336,7 @@
|
|||||||
"live_auto_position": "Auto-position",
|
"live_auto_position": "Auto-position",
|
||||||
"live_undo_hint": "Post sparad",
|
"live_undo_hint": "Post sparad",
|
||||||
"live_undo_btn": "Ångra",
|
"live_undo_btn": "Ångra",
|
||||||
|
"live_cancel": "Avbryt",
|
||||||
"live_pressure_placeholder": "t.ex. 1013",
|
"live_pressure_placeholder": "t.ex. 1013",
|
||||||
"live_temp_placeholder": "t.ex. 18",
|
"live_temp_placeholder": "t.ex. 18",
|
||||||
"live_precip_placeholder": "t.ex. lätt regn",
|
"live_precip_placeholder": "t.ex. lätt regn",
|
||||||
@@ -320,6 +359,7 @@
|
|||||||
"carry_over_tanks_yes": "Ta över",
|
"carry_over_tanks_yes": "Ta över",
|
||||||
"carry_over_tanks_no": "Börja med 0",
|
"carry_over_tanks_no": "Börja med 0",
|
||||||
"event_title": "Kronologisk händelselogg",
|
"event_title": "Kronologisk händelselogg",
|
||||||
|
"event_creator": "Registrerad av",
|
||||||
"no_events": "Inga händelser inlagda för denna resdag ännu.",
|
"no_events": "Inga händelser inlagda för denna resdag ännu.",
|
||||||
"event_time": "Tid på dygnet",
|
"event_time": "Tid på dygnet",
|
||||||
"event_mgk": "MgK-kurs",
|
"event_mgk": "MgK-kurs",
|
||||||
@@ -357,7 +397,26 @@
|
|||||||
"event_location_placeholder": "z. t.ex. Kiel",
|
"event_location_placeholder": "z. t.ex. Kiel",
|
||||||
"event_remarks": "Anmärkningar / incidenter",
|
"event_remarks": "Anmärkningar / incidenter",
|
||||||
"gps_btn": "Hämta GPS-koordinater",
|
"gps_btn": "Hämta GPS-koordinater",
|
||||||
|
"gps_permission_denied": "Platstillgång nekades. Tillåt det i webbläsar- eller enhetsinställningar och försök igen.",
|
||||||
|
"gps_timeout": "GPS fick tidsgräns. Försök igen utomhus med fri sikt mot himlen.",
|
||||||
|
"gps_position_unavailable": "Ingen GPS-signal tillgänglig. Vänta och försök igen, eller ange koordinater manuellt.",
|
||||||
|
"gps_unavailable": "GPS stöds inte av denna webbläsare eller enhet.",
|
||||||
|
"gps_failed": "GPS-position kunde inte bestämmas.",
|
||||||
|
"gps_fallback_no_location": "GPS misslyckades. Ange en plats under ort/hamn, avresa eller destination, eller skriv koordinater manuellt.",
|
||||||
|
"gps_fallback_success": "Koordinater för \"{{location}}\" hittades via ortsnamn (inte GPS).",
|
||||||
|
"gps_fallback_failed": "GPS och ortnamnssökning misslyckades. Ange koordinater manuellt.",
|
||||||
|
"gps_quality_excellent": "Stark GPS-mottagning (±{{accuracy}} m)",
|
||||||
|
"gps_quality_good": "Bra GPS-mottagning (±{{accuracy}} m)",
|
||||||
|
"gps_quality_fair": "Måttlig GPS-mottagning (±{{accuracy}} m) – gå utomhus för bättre signal.",
|
||||||
|
"gps_quality_poor": "Svag GPS-mottagning (±{{accuracy}} m) – troligen få satelliter. Försök utomhus igen eller kontrollera positionen.",
|
||||||
|
"gps_quality_unknown": "GPS-position övertagen (noggrannhet ej rapporterad av enheten).",
|
||||||
|
"gps_live_intro_title": "Plats för live-logg",
|
||||||
|
"gps_live_intro_body": "Appen behöver din plats för automatiska positionsregistreringar och GPS-knappen.\n\nTryck på „Tillåt plats“ och bekräfta i nästa dialog. Du kan alltid ange position manuellt via „Position“.",
|
||||||
|
"gps_live_intro_allow": "Tillåt plats",
|
||||||
|
"gps_live_intro_later": "Senare",
|
||||||
|
"gps_enable_in_settings_hint": "Platstillgång är blockerad. Du kan tillåta det senare i webbläsar- eller enhetsinställningar (webbplats / app → Plats).",
|
||||||
"weather_btn": "OpenWeatherMap Ring upp väder",
|
"weather_btn": "OpenWeatherMap Ring upp väder",
|
||||||
|
"weather_offline": "OpenWeatherMap kräver internetanslutning. Du är offline.",
|
||||||
"event_wind_pressure": "Lufttryck (hPa)",
|
"event_wind_pressure": "Lufttryck (hPa)",
|
||||||
"event_heel": "Krängning (°)",
|
"event_heel": "Krängning (°)",
|
||||||
"event_sails": "Segelhantering / motor",
|
"event_sails": "Segelhantering / motor",
|
||||||
@@ -371,10 +430,24 @@
|
|||||||
"share_csv": "Aktie",
|
"share_csv": "Aktie",
|
||||||
"export_pdf": "Hämta PDF.",
|
"export_pdf": "Hämta PDF.",
|
||||||
"exporting_pdf": "PDF genereras...",
|
"exporting_pdf": "PDF genereras...",
|
||||||
"photos_title": "Fotobilagor (E2E-krypterade)",
|
"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",
|
||||||
"photo_caption_label": "Fotobeskrivning/etikett (valfritt)",
|
"photo_caption_label": "Fotobeskrivning/etikett (valfritt)",
|
||||||
"photo_caption_placeholder": "t.ex. sätta segel nära hamninloppet",
|
"photo_caption_placeholder": "t.ex. sätta segel nära hamninloppet",
|
||||||
"photo_btn": "Ta foto / ladda upp",
|
"photo_btn": "Ta foto / ladda upp",
|
||||||
|
"photo_camera_btn": "Ta foto",
|
||||||
|
"photo_gallery_btn": "Välj från galleri",
|
||||||
"photo_processing": "Håller på att bearbetas...",
|
"photo_processing": "Håller på att bearbetas...",
|
||||||
"no_photos": "Inga foton kopplade till denna resdag ännu.",
|
"no_photos": "Inga foton kopplade till denna resdag ännu.",
|
||||||
"photo_delete_confirm": "Är du säker på att du vill radera det här fotot permanent?",
|
"photo_delete_confirm": "Är du säker på att du vill radera det här fotot permanent?",
|
||||||
@@ -449,8 +522,8 @@
|
|||||||
"nmea_change_engine_stop": "Engine off",
|
"nmea_change_engine_stop": "Engine off",
|
||||||
"nmea_change_autopilot_on": "Autopilot on",
|
"nmea_change_autopilot_on": "Autopilot on",
|
||||||
"nmea_change_autopilot_off": "Autopilot off",
|
"nmea_change_autopilot_off": "Autopilot off",
|
||||||
"nmea_change_gps_lost": "GPS fix lost",
|
"nmea_change_gps_lost": "GPS-position förlorad",
|
||||||
"nmea_change_gps_regained": "GPS fix restored",
|
"nmea_change_gps_regained": "GPS-position återställd",
|
||||||
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
|
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
|
||||||
"nmea_change_departure": "Departure / underway",
|
"nmea_change_departure": "Departure / underway",
|
||||||
"nmea_change_anchor": "Anchored / stop",
|
"nmea_change_anchor": "Anchored / stop",
|
||||||
@@ -464,7 +537,7 @@
|
|||||||
"new_logbook_placeholder": "Loggbokens eller båtens namn",
|
"new_logbook_placeholder": "Loggbokens eller båtens namn",
|
||||||
"logout": "Logga ut",
|
"logout": "Logga ut",
|
||||||
"logged_in_as": "Inloggad som {{name}}",
|
"logged_in_as": "Inloggad som {{name}}",
|
||||||
"delete_confirm": "Är du säker på att du vill radera den här loggboken permanent? Alla lokala data och serverkopior kommer att förstöras.\n\nTips: Skapa en säkerhetskopia (.daagbok.json) i förväg under Inställningar → Säkerhetskopiering och återställning om du vill behålla data senare.",
|
"delete_confirm": "Är du säker på att du vill radera den här loggboken permanent? Alla lokala data och serverkopior kommer att förstöras.\n\nTips: Skapa en säkerhetskopia (.daagbok) i förväg under Inställningar → Säkerhetskopiering och återställning om du vill behålla data senare.",
|
||||||
"no_logbooks": "Inga loggböcker hittades. Skapa din första loggbok för att komma igång!",
|
"no_logbooks": "Inga loggböcker hittades. Skapa din första loggbok för att komma igång!",
|
||||||
"loading": "Loggböckerna är fulla...",
|
"loading": "Loggböckerna är fulla...",
|
||||||
"status_synced": "Synkroniserad",
|
"status_synced": "Synkroniserad",
|
||||||
@@ -604,6 +677,12 @@
|
|||||||
"integrations_title": "Integrationer",
|
"integrations_title": "Integrationer",
|
||||||
"owm_key": "OpenWeatherMap API-nyckel",
|
"owm_key": "OpenWeatherMap API-nyckel",
|
||||||
"owm_help": "Valfritt: egen OpenWeatherMap API-nyckel. Om inget anges används nyckeln på serversidan från operatörskonfigurationen.",
|
"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_save": "Spara",
|
||||||
"prefs_saving": "Kommer att sparas...",
|
"prefs_saving": "Kommer att sparas...",
|
||||||
"prefs_saved": "Sparade",
|
"prefs_saved": "Sparade",
|
||||||
@@ -725,6 +804,9 @@
|
|||||||
"no_key": "Ingen OpenWeatherMap API-nyckel tillgänglig. Spara din egen nyckel i användarprofilen eller kontakta operatören.",
|
"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_success": "Väderdata har hämtats framgångsrikt!",
|
||||||
"weather_error": "Hämtning av väderdata misslyckades. Kontrollera API-nyckeln och anslutningen.",
|
"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}}.",
|
"weather_date_mismatch": "Väderdata kan endast hämtas för idag ({{today}}). Denna loggbokspost är daterad {{date}}.",
|
||||||
"gps_error": "Ange en plats eller bestäm GPS-koordinaterna.",
|
"gps_error": "Ange en plats eller bestäm GPS-koordinaterna.",
|
||||||
"share_title": "Aktieloggbok (skrivskyddad)",
|
"share_title": "Aktieloggbok (skrivskyddad)",
|
||||||
@@ -743,7 +825,7 @@
|
|||||||
"delete_account_confirm_yes": "Ja, radera konto och all data",
|
"delete_account_confirm_yes": "Ja, radera konto och all data",
|
||||||
"delete_account_confirm_no": "Avbryt",
|
"delete_account_confirm_no": "Avbryt",
|
||||||
"delete_account_failed": "Kontot kunde inte raderas. Vänligen försök igen.",
|
"delete_account_failed": "Kontot kunde inte raderas. Vänligen försök igen.",
|
||||||
"delete_backup_hint": "Tips: Skapa säkerhetskopior av dina loggböcker (.daagbok.json) i inställningarna för varje loggbok innan du raderar dem.",
|
"delete_backup_hint": "Tips: Skapa säkerhetskopior av dina loggböcker (.daagbok) i inställningarna för varje loggbok innan du raderar dem.",
|
||||||
"deleting_account": "Kontot kommer att raderas...",
|
"deleting_account": "Kontot kommer att raderas...",
|
||||||
"invite_push_prompt_title": "Aktivera push-meddelanden?",
|
"invite_push_prompt_title": "Aktivera push-meddelanden?",
|
||||||
"invite_push_prompt_message": "Så snart inbjudna Crew-medlemmar synkroniserar ändringar kan du bli informerad via push. Inget loggboksinnehåll skickas i klartext.",
|
"invite_push_prompt_message": "Så snart inbjudna Crew-medlemmar synkroniserar ändringar kan du bli informerad via push. Inget loggboksinnehåll skickas i klartext.",
|
||||||
@@ -754,7 +836,7 @@
|
|||||||
"backup_title": "Säkerhetskopiering och återställning",
|
"backup_title": "Säkerhetskopiering och återställning",
|
||||||
"backup_desc": "Komplett krypterad säkerhetskopia av denna loggbok (poster, foton, GPS-spår, crew, fartyg). Skyddad med lösenfras för säkerhetskopian - för återställning till detta eller ett nytt konto.",
|
"backup_desc": "Komplett krypterad säkerhetskopia av denna loggbok (poster, foton, GPS-spår, crew, fartyg). Skyddad med lösenfras för säkerhetskopian - för återställning till detta eller ett nytt konto.",
|
||||||
"backup_export_title": "Skapa säkerhetskopia",
|
"backup_export_title": "Skapa säkerhetskopia",
|
||||||
"backup_export_desc": "Laddar ner alla lokala data som .daagbok.json. Förvara filen och lösenfrasen separat och säkert.",
|
"backup_export_desc": "Laddar ner alla lokala data som ett komprimerat .daagbok-arkiv. Förvara filen och lösenfrasen separat och säkert.",
|
||||||
"backup_restore_title": "Återställ säkerhetskopian",
|
"backup_restore_title": "Återställ säkerhetskopian",
|
||||||
"backup_restore_desc": "Återställer en säkerhetskopia till ditt nuvarande konto - även efter att du har registrerat ett nytt konto.",
|
"backup_restore_desc": "Återställer en säkerhetskopia till ditt nuvarande konto - även efter att du har registrerat ett nytt konto.",
|
||||||
"backup_passphrase": "Lösenord för säkerhetskopiering",
|
"backup_passphrase": "Lösenord för säkerhetskopiering",
|
||||||
@@ -766,7 +848,13 @@
|
|||||||
"backup_export_btn": "Ladda ner backup",
|
"backup_export_btn": "Ladda ner backup",
|
||||||
"backup_exporting": "Säkerhetskopian skapas...",
|
"backup_exporting": "Säkerhetskopian skapas...",
|
||||||
"backup_export_success": "Säkerhetskopia skapad ({{count}} resdagar).",
|
"backup_export_success": "Säkerhetskopia skapad ({{count}} resdagar).",
|
||||||
"backup_file_label": "Säkerhetskopieringsfil (.daagbok.json)",
|
"backup_file_label": "Säkerhetskopieringsfil (.daagbok)",
|
||||||
|
"backup_export_progress": "Packar filer {{current}} / {{total}}…",
|
||||||
|
"backup_invalid_archive": "Filen är inte ett giltigt backup-arkiv.",
|
||||||
|
"backup_version_unsupported": "Gammalt backup-format (v1). Använd en aktuell .daagbok-säkerhetskopia.",
|
||||||
|
"backup_import_size_confirm": "Denna säkerhetskopia är ca. {{size}} okomprimerad. Återställning kan ta längre tid. Fortsätta?",
|
||||||
|
"backup_stat_voice": "{{count}} röstanteckningar",
|
||||||
|
"backup_stat_size": "Ca. {{size}} okomprimerat",
|
||||||
"backup_preview_btn": "Kontrollera innehåll",
|
"backup_preview_btn": "Kontrollera innehåll",
|
||||||
"backup_previewing": "Check...",
|
"backup_previewing": "Check...",
|
||||||
"backup_restore_btn": "Återställ",
|
"backup_restore_btn": "Återställ",
|
||||||
|
|||||||
@@ -18,3 +18,62 @@ body {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Scrollbars — auf Touch-Geräten breiter und besser sichtbar */
|
||||||
|
:root {
|
||||||
|
--app-scrollbar-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: none), (pointer: coarse), (max-width: 768px) {
|
||||||
|
:root {
|
||||||
|
--app-scrollbar-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scrollbar-width: auto;
|
||||||
|
scrollbar-color: var(--app-accent-light) var(--app-surface-inset);
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
html::-webkit-scrollbar,
|
||||||
|
body::-webkit-scrollbar,
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
width: var(--app-scrollbar-size);
|
||||||
|
height: var(--app-scrollbar-size);
|
||||||
|
}
|
||||||
|
|
||||||
|
html::-webkit-scrollbar-track,
|
||||||
|
body::-webkit-scrollbar-track,
|
||||||
|
*::-webkit-scrollbar-track {
|
||||||
|
background: var(--app-surface-inset);
|
||||||
|
border-radius: calc(var(--app-scrollbar-size) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
html::-webkit-scrollbar-thumb,
|
||||||
|
body::-webkit-scrollbar-thumb,
|
||||||
|
*::-webkit-scrollbar-thumb {
|
||||||
|
background: color-mix(in srgb, var(--app-accent-light) 55%, transparent);
|
||||||
|
border-radius: calc(var(--app-scrollbar-size) / 2);
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html::-webkit-scrollbar-thumb:hover,
|
||||||
|
body::-webkit-scrollbar-thumb:hover,
|
||||||
|
*::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: color-mix(in srgb, var(--app-accent-light) 80%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: none), (pointer: coarse), (max-width: 768px) {
|
||||||
|
html::-webkit-scrollbar-thumb,
|
||||||
|
body::-webkit-scrollbar-thumb,
|
||||||
|
*::-webkit-scrollbar-thumb {
|
||||||
|
background: color-mix(in srgb, var(--app-accent-light) 70%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
html::-webkit-scrollbar-thumb:active,
|
||||||
|
body::-webkit-scrollbar-thumb:active,
|
||||||
|
*::-webkit-scrollbar-thumb:active {
|
||||||
|
background: var(--app-accent-light);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
reconcileVersionOnStartup
|
reconcileVersionOnStartup
|
||||||
} from './services/pwaStartup.ts'
|
} from './services/pwaStartup.ts'
|
||||||
import { redirectToPasskeyCompatibleHostIfNeeded } from './utils/passkeyHost.ts'
|
import { redirectToPasskeyCompatibleHostIfNeeded } from './utils/passkeyHost.ts'
|
||||||
import { logToBackend } from './services/pushNotifications.ts'
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@@ -75,16 +74,13 @@ async function bootstrap(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ('serviceWorker' in navigator && !import.meta.env.DEV) {
|
if ('serviceWorker' in navigator && !import.meta.env.DEV) {
|
||||||
logToBackend('Attempting manual Service Worker registration...')
|
|
||||||
navigator.serviceWorker
|
navigator.serviceWorker
|
||||||
.register('/sw.js', { scope: '/' })
|
.register('/sw.js', { scope: '/' })
|
||||||
.then((reg) => {
|
.then((reg) => {
|
||||||
console.log('Service Worker registered successfully with scope:', reg.scope)
|
console.log('Service Worker registered successfully with scope:', reg.scope)
|
||||||
logToBackend('Service Worker registered successfully with scope: ' + reg.scope)
|
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error('Service Worker registration failed:', err)
|
console.error('Service Worker registration failed:', err)
|
||||||
logToBackend('Service Worker registration failed', err)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
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
|
||||||
|
dbSize: 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_ENABLED: 'Push Enabled',
|
||||||
PUSH_DISABLED: 'Push Disabled',
|
PUSH_DISABLED: 'Push Disabled',
|
||||||
FOOTER_LINK_CLICKED: 'Footer Link Clicked',
|
FOOTER_LINK_CLICKED: 'Footer Link Clicked',
|
||||||
|
KOFI_LINK_CLICKED: 'Ko-fi Link Clicked',
|
||||||
PROFILE_OPENED: 'Profile Opened',
|
PROFILE_OPENED: 'Profile Opened',
|
||||||
PASSKEY_ADDED: 'Passkey Added',
|
PASSKEY_ADDED: 'Passkey Added',
|
||||||
PASSKEY_REMOVED: 'Passkey Removed',
|
PASSKEY_REMOVED: 'Passkey Removed',
|
||||||
@@ -40,8 +41,10 @@ export const PlausibleEvents = {
|
|||||||
NMEA_UPLOADED: 'NMEA Uploaded',
|
NMEA_UPLOADED: 'NMEA Uploaded',
|
||||||
LIVE_LOG_OPENED: 'Live Log Opened',
|
LIVE_LOG_OPENED: 'Live Log Opened',
|
||||||
LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged',
|
LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged',
|
||||||
LIVE_LOG_PHOTO_UPLOADED: 'Live Log Photo Uploaded',
|
VOICE_MEMO_UPLOADED: 'Voice Memo Uploaded',
|
||||||
|
VOICE_MEMO_TRANSCRIBED: 'Voice Memo Transcribed',
|
||||||
OWM_WEATHER_FETCHED: 'OWM Weather Fetched',
|
OWM_WEATHER_FETCHED: 'OWM Weather Fetched',
|
||||||
|
AI_SUMMARY_GENERATED: 'AI Summary Generated',
|
||||||
PWA_BOOT_WATCHDOG_SOFT: 'PWA Boot Watchdog Soft',
|
PWA_BOOT_WATCHDOG_SOFT: 'PWA Boot Watchdog Soft',
|
||||||
PWA_BOOT_WATCHDOG_HARD: 'PWA Boot Watchdog Hard',
|
PWA_BOOT_WATCHDOG_HARD: 'PWA Boot Watchdog Hard',
|
||||||
PWA_BOOT_WATCHDOG_FALLBACK: 'PWA Boot Watchdog Fallback',
|
PWA_BOOT_WATCHDOG_FALLBACK: 'PWA Boot Watchdog Fallback',
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ describe('appearancePrefs', () => {
|
|||||||
await expect(fetchAppearancePrefs()).resolves.toEqual({
|
await expect(fetchAppearancePrefs()).resolves.toEqual({
|
||||||
theme: 'auto',
|
theme: 'auto',
|
||||||
colorScheme: 'auto',
|
colorScheme: 'auto',
|
||||||
|
aiAuthorized: false,
|
||||||
persisted: false
|
persisted: false
|
||||||
})
|
})
|
||||||
expect(mockedApiJson).not.toHaveBeenCalled()
|
expect(mockedApiJson).not.toHaveBeenCalled()
|
||||||
@@ -36,6 +37,7 @@ describe('appearancePrefs', () => {
|
|||||||
mockedApiJson.mockResolvedValueOnce({
|
mockedApiJson.mockResolvedValueOnce({
|
||||||
theme: 'ocean',
|
theme: 'ocean',
|
||||||
colorScheme: 'dark',
|
colorScheme: 'dark',
|
||||||
|
aiAuthorized: true,
|
||||||
persisted: true
|
persisted: true
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -46,6 +48,7 @@ describe('appearancePrefs', () => {
|
|||||||
|
|
||||||
expect(localStorage.getItem(`user_pref_theme_${USER_ID}`)).toBe('ocean')
|
expect(localStorage.getItem(`user_pref_theme_${USER_ID}`)).toBe('ocean')
|
||||||
expect(localStorage.getItem(`user_pref_color_scheme_${USER_ID}`)).toBe('dark')
|
expect(localStorage.getItem(`user_pref_color_scheme_${USER_ID}`)).toBe('dark')
|
||||||
|
expect(localStorage.getItem(`user_pref_ai_authorized_${USER_ID}`)).toBe('true')
|
||||||
expect(changed).toHaveBeenCalledTimes(1)
|
expect(changed).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -53,20 +56,20 @@ describe('appearancePrefs', () => {
|
|||||||
localStorage.setItem('active_userid', USER_ID)
|
localStorage.setItem('active_userid', USER_ID)
|
||||||
setThemePreference(USER_ID, 'material')
|
setThemePreference(USER_ID, 'material')
|
||||||
mockedApiJson
|
mockedApiJson
|
||||||
.mockResolvedValueOnce({ theme: 'auto', colorScheme: 'auto', persisted: false })
|
.mockResolvedValueOnce({ theme: 'auto', colorScheme: 'auto', aiAuthorized: false, persisted: false })
|
||||||
.mockResolvedValueOnce({ theme: 'material', colorScheme: 'auto', persisted: true })
|
.mockResolvedValueOnce({ theme: 'material', colorScheme: 'auto', aiAuthorized: false, persisted: true })
|
||||||
|
|
||||||
await syncAppearancePrefs(USER_ID)
|
await syncAppearancePrefs(USER_ID)
|
||||||
|
|
||||||
expect(mockedApiJson).toHaveBeenCalledTimes(2)
|
expect(mockedApiJson).toHaveBeenCalledTimes(2)
|
||||||
expect(mockedApiJson).toHaveBeenLastCalledWith('/api/auth/appearance-prefs', {
|
expect(mockedApiJson).toHaveBeenLastCalledWith('/api/auth/appearance-prefs', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ theme: 'material', colorScheme: 'auto' })
|
body: JSON.stringify({ theme: 'material', colorScheme: 'auto', aiAuthorized: false })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('saveAppearancePrefsToServer skips when not authenticated', async () => {
|
it('saveAppearancePrefsToServer skips when not authenticated', async () => {
|
||||||
await saveAppearancePrefsToServer('ocean', 'light')
|
await saveAppearancePrefsToServer('ocean', 'light', true)
|
||||||
expect(mockedApiJson).not.toHaveBeenCalled()
|
expect(mockedApiJson).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -76,6 +79,7 @@ describe('appearancePrefs', () => {
|
|||||||
mockedApiJson.mockResolvedValue({
|
mockedApiJson.mockResolvedValue({
|
||||||
theme: 'material',
|
theme: 'material',
|
||||||
colorScheme: 'dark',
|
colorScheme: 'dark',
|
||||||
|
aiAuthorized: false,
|
||||||
persisted: true
|
persisted: true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import {
|
|||||||
getColorSchemePreference,
|
getColorSchemePreference,
|
||||||
getThemePreference,
|
getThemePreference,
|
||||||
setColorSchemePreference,
|
setColorSchemePreference,
|
||||||
setThemePreference
|
setThemePreference,
|
||||||
|
getAiAuthorized,
|
||||||
|
setAiAuthorized
|
||||||
} from './userPreferences.js'
|
} from './userPreferences.js'
|
||||||
|
|
||||||
const API_BASE = '/api/auth/appearance-prefs'
|
const API_BASE = '/api/auth/appearance-prefs'
|
||||||
@@ -13,13 +15,15 @@ const API_BASE = '/api/auth/appearance-prefs'
|
|||||||
export interface AppearancePrefs {
|
export interface AppearancePrefs {
|
||||||
theme: string
|
theme: string
|
||||||
colorScheme: string
|
colorScheme: string
|
||||||
|
aiAuthorized: boolean
|
||||||
persisted: boolean
|
persisted: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasLocalAppearancePrefs(userId: string): boolean {
|
function hasLocalAppearancePrefs(userId: string): boolean {
|
||||||
return (
|
return (
|
||||||
localStorage.getItem(`user_pref_theme_${userId}`) != null ||
|
localStorage.getItem(`user_pref_theme_${userId}`) != null ||
|
||||||
localStorage.getItem(`user_pref_color_scheme_${userId}`) != null
|
localStorage.getItem(`user_pref_color_scheme_${userId}`) != null ||
|
||||||
|
localStorage.getItem(`user_pref_ai_authorized_${userId}`) != null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +39,7 @@ function resolveSyncedUserId(userId?: string | null): string | null {
|
|||||||
|
|
||||||
export async function fetchAppearancePrefs(userId?: string | null): Promise<AppearancePrefs> {
|
export async function fetchAppearancePrefs(userId?: string | null): Promise<AppearancePrefs> {
|
||||||
if (!resolveSyncedUserId(userId)) {
|
if (!resolveSyncedUserId(userId)) {
|
||||||
return { theme: 'auto', colorScheme: 'auto', persisted: false }
|
return { theme: 'auto', colorScheme: 'auto', aiAuthorized: false, persisted: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
return apiJson<AppearancePrefs>(API_BASE)
|
return apiJson<AppearancePrefs>(API_BASE)
|
||||||
@@ -44,13 +48,14 @@ export async function fetchAppearancePrefs(userId?: string | null): Promise<Appe
|
|||||||
export async function saveAppearancePrefsToServer(
|
export async function saveAppearancePrefsToServer(
|
||||||
theme: string,
|
theme: string,
|
||||||
colorScheme: string,
|
colorScheme: string,
|
||||||
|
aiAuthorized: boolean,
|
||||||
userId?: string | null
|
userId?: string | null
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!resolveSyncedUserId(userId)) return
|
if (!resolveSyncedUserId(userId)) return
|
||||||
|
|
||||||
await apiJson<AppearancePrefs>(API_BASE, {
|
await apiJson<AppearancePrefs>(API_BASE, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ theme, colorScheme })
|
body: JSON.stringify({ theme, colorScheme, aiAuthorized })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,8 +70,14 @@ export async function syncAppearancePrefs(userId?: string | null): Promise<void>
|
|||||||
if (server.persisted) {
|
if (server.persisted) {
|
||||||
setThemePreference(id, server.theme)
|
setThemePreference(id, server.theme)
|
||||||
setColorSchemePreference(id, server.colorScheme)
|
setColorSchemePreference(id, server.colorScheme)
|
||||||
|
setAiAuthorized(id, server.aiAuthorized)
|
||||||
} else if (hasLocalAppearancePrefs(id)) {
|
} else if (hasLocalAppearancePrefs(id)) {
|
||||||
await saveAppearancePrefsToServer(getThemePreference(id), getColorSchemePreference(id), id)
|
await saveAppearancePrefsToServer(
|
||||||
|
getThemePreference(id),
|
||||||
|
getColorSchemePreference(id),
|
||||||
|
getAiAuthorized(id),
|
||||||
|
id
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Failed to sync appearance preferences:', err)
|
console.warn('Failed to sync appearance preferences:', err)
|
||||||
|
|||||||
@@ -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> {
|
export async function reauthWithPasskey(): Promise<boolean> {
|
||||||
const options = await apiJson<any>(`${API_BASE}/reauth-options`, {
|
const options = await apiJson<any>(`${API_BASE}/reauth-options`, {
|
||||||
method: 'POST'
|
method: 'POST'
|
||||||
@@ -556,6 +565,7 @@ export async function deleteAccount(): Promise<boolean> {
|
|||||||
db.deviations.clear(),
|
db.deviations.clear(),
|
||||||
db.entries.clear(),
|
db.entries.clear(),
|
||||||
db.photos.clear(),
|
db.photos.clear(),
|
||||||
|
db.voiceMemos.clear(),
|
||||||
db.gpsTracks.clear(),
|
db.gpsTracks.clear(),
|
||||||
db.syncQueue.clear(),
|
db.syncQueue.clear(),
|
||||||
db.logbookKeys.clear(),
|
db.logbookKeys.clear(),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest'
|
|||||||
import {
|
import {
|
||||||
hasUnlockedLocalCrypto,
|
hasUnlockedLocalCrypto,
|
||||||
hasUnlockedLocalSession,
|
hasUnlockedLocalSession,
|
||||||
|
resolveRestoreUsername,
|
||||||
setActiveMasterKey
|
setActiveMasterKey
|
||||||
} from './auth.js'
|
} 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', () => {
|
describe('persistSessionUserId', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear()
|
localStorage.clear()
|
||||||
|
|||||||
@@ -74,10 +74,10 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
|||||||
|
|
||||||
// Headers matching the requested event fields & metadata
|
// Headers matching the requested event fields & metadata
|
||||||
const headers = [
|
const headers = [
|
||||||
'Date', 'Day of Travel', 'Departure Port', 'Destination Port',
|
'Date', 'Day of Travel', 'Departure Port', 'Destination Port', 'AI Summary',
|
||||||
'Skipper Signature', 'Crew Signature',
|
'Skipper Signature', 'Crew Signature',
|
||||||
'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)', 'Motor Hours (h)',
|
'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)', 'Motor Hours (h)',
|
||||||
'Event Time', 'MgK Course', 'RwK Course',
|
'Event Time', 'Event Creator', 'MgK Course', 'RwK Course',
|
||||||
'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State', 'Visibility',
|
'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State', 'Visibility',
|
||||||
'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)',
|
'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)',
|
||||||
'Latitude', 'Longitude', 'Remarks',
|
'Latitude', 'Longitude', 'Remarks',
|
||||||
@@ -120,15 +120,17 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
|||||||
const fuelE = entry.fuel?.evening ?? '';
|
const fuelE = entry.fuel?.evening ?? '';
|
||||||
const fuelCons = entry.fuel?.consumption ?? '';
|
const fuelCons = entry.fuel?.consumption ?? '';
|
||||||
const greywaterLevel = entry.greywater?.level ?? '';
|
const greywaterLevel = entry.greywater?.level ?? '';
|
||||||
|
const aiSummary = entry.aiSummary ?? '';
|
||||||
|
|
||||||
|
const crewSnapshots = (entry.crewSnapshotsById as Record<string, any>) || {};
|
||||||
const eventsList = entry.events || [];
|
const eventsList = entry.events || [];
|
||||||
if (eventsList.length === 0) {
|
if (eventsList.length === 0) {
|
||||||
// Create one row even if there are no events for the day
|
// Create one row even if there are no events for the day
|
||||||
rows.push([
|
rows.push([
|
||||||
dateVal, travelDay, dep, dest,
|
dateVal, travelDay, dep, dest, aiSummary,
|
||||||
signS, signC,
|
signS, signC,
|
||||||
trackDist, trackMax, trackAvg, motorH,
|
trackDist, trackMax, trackAvg, motorH,
|
||||||
'', '', '',
|
'', '', '', '',
|
||||||
'', '', '', '', '',
|
'', '', '', '', '',
|
||||||
'', '', '', '', '',
|
'', '', '', '', '',
|
||||||
'', '', '',
|
'', '', '',
|
||||||
@@ -141,11 +143,21 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
|||||||
// Sort events chronologically by time
|
// Sort events chronologically by time
|
||||||
const sortedEvents = sortLogEventsByTime(eventsList);
|
const sortedEvents = sortLogEventsByTime(eventsList);
|
||||||
for (const ev of sortedEvents) {
|
for (const ev of sortedEvents) {
|
||||||
|
const creatorSnap = ev.creatorId ? crewSnapshots[ev.creatorId] : null;
|
||||||
|
let creatorName = '';
|
||||||
|
if (creatorSnap) {
|
||||||
|
creatorName = creatorSnap.name || '';
|
||||||
|
} else if (ev.creatorId === 'skipper') {
|
||||||
|
creatorName = 'Skipper';
|
||||||
|
} else if (ev.creatorId) {
|
||||||
|
creatorName = ev.creatorId;
|
||||||
|
}
|
||||||
|
|
||||||
rows.push([
|
rows.push([
|
||||||
dateVal, travelDay, dep, dest,
|
dateVal, travelDay, dep, dest, aiSummary,
|
||||||
signS, signC,
|
signS, signC,
|
||||||
trackDist, trackMax, trackAvg, motorH,
|
trackDist, trackMax, trackAvg, motorH,
|
||||||
ev.time || '', ev.mgk || '', ev.rwk || '',
|
ev.time || '', creatorName, ev.mgk || '', ev.rwk || '',
|
||||||
ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '',
|
ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '',
|
||||||
ev.visibility || '',
|
ev.visibility || '',
|
||||||
ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '',
|
ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '',
|
||||||
|
|||||||
@@ -65,6 +65,16 @@ export interface LocalPhoto {
|
|||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LocalVoiceMemo {
|
||||||
|
payloadId: string
|
||||||
|
entryId: string
|
||||||
|
logbookId: string
|
||||||
|
encryptedData: string
|
||||||
|
iv: string
|
||||||
|
tag: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface LocalGpsTrack {
|
export interface LocalGpsTrack {
|
||||||
entryId: string // one track per daily journal entry
|
entryId: string // one track per daily journal entry
|
||||||
logbookId: string
|
logbookId: string
|
||||||
@@ -132,6 +142,7 @@ export interface SyncQueueItem {
|
|||||||
| 'entry'
|
| 'entry'
|
||||||
| 'logbook'
|
| 'logbook'
|
||||||
| 'photo'
|
| 'photo'
|
||||||
|
| 'voiceMemo'
|
||||||
| 'gpsTrack'
|
| 'gpsTrack'
|
||||||
| 'logbookCrew'
|
| 'logbookCrew'
|
||||||
| 'logbookVessel'
|
| 'logbookVessel'
|
||||||
@@ -166,6 +177,7 @@ class DaagboxDatabase extends Dexie {
|
|||||||
deviations!: Table<LocalDeviation>
|
deviations!: Table<LocalDeviation>
|
||||||
entries!: Table<LocalEntry>
|
entries!: Table<LocalEntry>
|
||||||
photos!: Table<LocalPhoto>
|
photos!: Table<LocalPhoto>
|
||||||
|
voiceMemos!: Table<LocalVoiceMemo>
|
||||||
gpsTracks!: Table<LocalGpsTrack>
|
gpsTracks!: Table<LocalGpsTrack>
|
||||||
nmeaArchives!: Table<LocalNmeaArchive>
|
nmeaArchives!: Table<LocalNmeaArchive>
|
||||||
logbookKeys!: Table<LocalLogbookKey>
|
logbookKeys!: Table<LocalLogbookKey>
|
||||||
@@ -289,6 +301,25 @@ class DaagboxDatabase extends Dexie {
|
|||||||
userSyncQueue: '++id, action, type, payloadId',
|
userSyncQueue: '++id, action, type, payloadId',
|
||||||
entryDrafts: '[logbookId+entryId], updatedAt'
|
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'
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -283,6 +283,7 @@ export async function deleteLocalLogbookCache(id: string): Promise<void> {
|
|||||||
await db.deviations.where({ logbookId: id }).delete()
|
await db.deviations.where({ logbookId: id }).delete()
|
||||||
await db.entries.where({ logbookId: id }).delete()
|
await db.entries.where({ logbookId: id }).delete()
|
||||||
await db.photos.where({ logbookId: id }).delete()
|
await db.photos.where({ logbookId: id }).delete()
|
||||||
|
await db.voiceMemos.where({ logbookId: id }).delete()
|
||||||
await db.gpsTracks.where({ logbookId: id }).delete()
|
await db.gpsTracks.where({ logbookId: id }).delete()
|
||||||
await db.syncQueue.where({ logbookId: id }).delete()
|
await db.syncQueue.where({ logbookId: id }).delete()
|
||||||
await db.logbookKeys.where({ logbookId: id }).delete()
|
await db.logbookKeys.where({ logbookId: id }).delete()
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { formatAppDecimal } from '../utils/numberFormat.js'
|
||||||
import { db } from './db.js'
|
import { db } from './db.js'
|
||||||
import { getActiveMasterKey } from './auth.js'
|
import { getActiveMasterKey } from './auth.js'
|
||||||
import {
|
import {
|
||||||
@@ -9,89 +10,54 @@ import { decryptLogbookTitle, deleteLocalLogbookCache } from './logbook.js'
|
|||||||
import { ensureLogbookKey, getLogbookKey, saveLogbookKey } from './logbookKeys.js'
|
import { ensureLogbookKey, getLogbookKey, saveLogbookKey } from './logbookKeys.js'
|
||||||
import { syncLogbook } from './sync.js'
|
import { syncLogbook } from './sync.js'
|
||||||
import type { SyncQueueItem } from './db.js'
|
import type { SyncQueueItem } from './db.js'
|
||||||
|
import { getAppVersion } from './pwaVersion.js'
|
||||||
|
import { dexieFieldsFromEncBytes, encBytesFromDexieFields } from './logbookBackup/encBlob.js'
|
||||||
|
import {
|
||||||
|
BACKUP_FORMAT,
|
||||||
|
BACKUP_VERSION,
|
||||||
|
type BackupManifestCounts,
|
||||||
|
type BackupManifestV2,
|
||||||
|
type LogbookMetaJson
|
||||||
|
} from './logbookBackup/manifest.js'
|
||||||
|
import {
|
||||||
|
buildArchiveFromCollected,
|
||||||
|
collectLogbookBackupData,
|
||||||
|
type BackupExportProgress
|
||||||
|
} from './logbookBackup/collector.js'
|
||||||
|
import {
|
||||||
|
isZipArchive,
|
||||||
|
readBinaryFile,
|
||||||
|
readManifestFromArchive,
|
||||||
|
readTextFile,
|
||||||
|
unzipArchive
|
||||||
|
} from './logbookBackup/zipArchive.js'
|
||||||
|
|
||||||
export const BACKUP_FORMAT = 'kapteins-daagbok-backup' as const
|
export { BACKUP_FORMAT, BACKUP_VERSION }
|
||||||
export const BACKUP_VERSION = 1 as const
|
export type { BackupExportProgress, BackupManifestCounts, BackupManifestV2 }
|
||||||
|
|
||||||
export interface LogbookBackupFile {
|
|
||||||
format: typeof BACKUP_FORMAT
|
|
||||||
version: typeof BACKUP_VERSION
|
|
||||||
exportedAt: string
|
|
||||||
logbook: {
|
|
||||||
id: string
|
|
||||||
encryptedTitle: string
|
|
||||||
updatedAt: string
|
|
||||||
isDemo?: boolean
|
|
||||||
}
|
|
||||||
logbookKey: {
|
|
||||||
ciphertext: string
|
|
||||||
iv: string
|
|
||||||
tag: string
|
|
||||||
}
|
|
||||||
payloads: {
|
|
||||||
yacht: {
|
|
||||||
encryptedData: string
|
|
||||||
iv: string
|
|
||||||
tag: string
|
|
||||||
updatedAt: string
|
|
||||||
} | null
|
|
||||||
deviation: {
|
|
||||||
encryptedData: string
|
|
||||||
iv: string
|
|
||||||
tag: string
|
|
||||||
updatedAt: string
|
|
||||||
} | null
|
|
||||||
crews: Array<{
|
|
||||||
payloadId: string
|
|
||||||
encryptedData: string
|
|
||||||
iv: string
|
|
||||||
tag: string
|
|
||||||
updatedAt: string
|
|
||||||
}>
|
|
||||||
entries: Array<{
|
|
||||||
payloadId: string
|
|
||||||
encryptedData: string
|
|
||||||
iv: string
|
|
||||||
tag: string
|
|
||||||
updatedAt: string
|
|
||||||
}>
|
|
||||||
photos: Array<{
|
|
||||||
payloadId: string
|
|
||||||
entryId: string
|
|
||||||
encryptedData: string
|
|
||||||
iv: string
|
|
||||||
tag: string
|
|
||||||
updatedAt: string
|
|
||||||
}>
|
|
||||||
gpsTracks: Array<{
|
|
||||||
entryId: string
|
|
||||||
encryptedData: string
|
|
||||||
iv: string
|
|
||||||
tag: string
|
|
||||||
updatedAt: string
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
counts: {
|
|
||||||
entries: number
|
|
||||||
photos: number
|
|
||||||
crews: number
|
|
||||||
gpsTracks: number
|
|
||||||
hasYacht: boolean
|
|
||||||
hasDeviation: boolean
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LogbookBackupPreview {
|
export interface LogbookBackupPreview {
|
||||||
title: string
|
title: string
|
||||||
exportedAt: string
|
exportedAt: string
|
||||||
sourceLogbookId: string
|
sourceLogbookId: string
|
||||||
counts: LogbookBackupFile['counts']
|
counts: BackupManifestCounts
|
||||||
|
totalUncompressedBytes: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ParsedLogbookBackup {
|
||||||
|
manifest: BackupManifestV2
|
||||||
|
files: Record<string, Uint8Array>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportLogbookBackupOptions {
|
||||||
|
onProgress?: (progress: BackupExportProgress) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const BACKUP_PASSPHRASE_SALT = 'KapteinsDaagbokBackupFileSalt_v1'
|
||||||
|
|
||||||
async function deriveBackupPassphraseKey(passphrase: string): Promise<CryptoKey> {
|
async function deriveBackupPassphraseKey(passphrase: string): Promise<CryptoKey> {
|
||||||
const encoder = new TextEncoder()
|
const encoder = new TextEncoder()
|
||||||
const passphraseBytes = encoder.encode(passphrase.trim())
|
const passphraseBytes = encoder.encode(passphrase.trim())
|
||||||
const saltBytes = encoder.encode('KapteinsDaagbokBackupFileSalt_v1')
|
const saltBytes = encoder.encode(BACKUP_PASSPHRASE_SALT)
|
||||||
|
|
||||||
const baseKey = await window.crypto.subtle.importKey(
|
const baseKey = await window.crypto.subtle.importKey(
|
||||||
'raw',
|
'raw',
|
||||||
@@ -120,26 +86,17 @@ async function wrapLogbookKey(logbookKey: ArrayBuffer, passphrase: string) {
|
|||||||
return encryptBuffer(logbookKey, key)
|
return encryptBuffer(logbookKey, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function unwrapLogbookKey(
|
async function unwrapLogbookKeyFromEnc(
|
||||||
wrapped: LogbookBackupFile['logbookKey'],
|
keyEnc: Uint8Array,
|
||||||
passphrase: string
|
passphrase: string
|
||||||
): Promise<ArrayBuffer> {
|
): Promise<ArrayBuffer> {
|
||||||
const key = await deriveBackupPassphraseKey(passphrase)
|
try {
|
||||||
return decryptBuffer(wrapped.ciphertext, wrapped.iv, wrapped.tag, key)
|
const fields = dexieFieldsFromEncBytes(keyEnc)
|
||||||
}
|
const cryptoKey = await deriveBackupPassphraseKey(passphrase)
|
||||||
|
return decryptBuffer(fields.encryptedData, fields.iv, fields.tag, cryptoKey)
|
||||||
function isBackupFile(value: unknown): value is LogbookBackupFile {
|
} catch {
|
||||||
if (!value || typeof value !== 'object') return false
|
throw new Error('BACKUP_WRONG_PASSPHRASE')
|
||||||
const obj = value as Partial<LogbookBackupFile>
|
}
|
||||||
return (
|
|
||||||
obj.format === BACKUP_FORMAT &&
|
|
||||||
obj.version === BACKUP_VERSION &&
|
|
||||||
typeof obj.exportedAt === 'string' &&
|
|
||||||
!!obj.logbook?.id &&
|
|
||||||
!!obj.logbook?.encryptedTitle &&
|
|
||||||
!!obj.logbookKey?.ciphertext &&
|
|
||||||
!!obj.payloads
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function encryptedPayloadData(
|
function encryptedPayloadData(
|
||||||
@@ -156,96 +113,12 @@ function encryptedPayloadData(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function collectLogbookPayloads(logbookId: string): Promise<LogbookBackupFile['payloads']> {
|
|
||||||
const [yacht, deviation, crews, entries, photos, gpsTracks] = await Promise.all([
|
|
||||||
db.yachts.get(logbookId),
|
|
||||||
db.deviations.get(logbookId),
|
|
||||||
db.crews.where({ logbookId }).toArray(),
|
|
||||||
db.entries.where({ logbookId }).toArray(),
|
|
||||||
db.photos.where({ logbookId }).toArray(),
|
|
||||||
db.gpsTracks.where({ logbookId }).toArray()
|
|
||||||
])
|
|
||||||
|
|
||||||
return {
|
|
||||||
yacht: yacht
|
|
||||||
? {
|
|
||||||
encryptedData: yacht.encryptedData,
|
|
||||||
iv: yacht.iv,
|
|
||||||
tag: yacht.tag,
|
|
||||||
updatedAt: yacht.updatedAt
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
deviation: deviation
|
|
||||||
? {
|
|
||||||
encryptedData: deviation.encryptedData,
|
|
||||||
iv: deviation.iv,
|
|
||||||
tag: deviation.tag,
|
|
||||||
updatedAt: deviation.updatedAt
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
crews: crews.map((c) => ({
|
|
||||||
payloadId: c.payloadId,
|
|
||||||
encryptedData: c.encryptedData,
|
|
||||||
iv: c.iv,
|
|
||||||
tag: c.tag,
|
|
||||||
updatedAt: c.updatedAt
|
|
||||||
})),
|
|
||||||
entries: entries.map((e) => ({
|
|
||||||
payloadId: e.payloadId,
|
|
||||||
encryptedData: e.encryptedData,
|
|
||||||
iv: e.iv,
|
|
||||||
tag: e.tag,
|
|
||||||
updatedAt: e.updatedAt
|
|
||||||
})),
|
|
||||||
photos: photos.map((p) => ({
|
|
||||||
payloadId: p.payloadId,
|
|
||||||
entryId: p.entryId,
|
|
||||||
encryptedData: p.encryptedData,
|
|
||||||
iv: p.iv,
|
|
||||||
tag: p.tag,
|
|
||||||
updatedAt: p.updatedAt
|
|
||||||
})),
|
|
||||||
gpsTracks: gpsTracks.map((t) => ({
|
|
||||||
entryId: t.entryId,
|
|
||||||
encryptedData: t.encryptedData,
|
|
||||||
iv: t.iv,
|
|
||||||
tag: t.tag,
|
|
||||||
updatedAt: t.updatedAt
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function remapBackup(
|
|
||||||
backup: LogbookBackupFile,
|
|
||||||
newLogbookId: string
|
|
||||||
): LogbookBackupFile {
|
|
||||||
return {
|
|
||||||
...backup,
|
|
||||||
logbook: {
|
|
||||||
...backup.logbook,
|
|
||||||
id: newLogbookId
|
|
||||||
},
|
|
||||||
payloads: {
|
|
||||||
...backup.payloads,
|
|
||||||
yacht: backup.payloads.yacht
|
|
||||||
? { ...backup.payloads.yacht, updatedAt: backup.payloads.yacht.updatedAt }
|
|
||||||
: null,
|
|
||||||
deviation: backup.payloads.deviation
|
|
||||||
? { ...backup.payloads.deviation, updatedAt: backup.payloads.deviation.updatedAt }
|
|
||||||
: null,
|
|
||||||
crews: backup.payloads.crews.map((c) => ({ ...c })),
|
|
||||||
entries: backup.payloads.entries.map((e) => ({ ...e })),
|
|
||||||
photos: backup.payloads.photos.map((p) => ({ ...p })),
|
|
||||||
gpsTracks: backup.payloads.gpsTracks.map((t) => ({ ...t }))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function queueRestoredLogbookForSync(
|
async function queueRestoredLogbookForSync(
|
||||||
logbookId: string,
|
logbookId: string,
|
||||||
encryptedTitle: string,
|
encryptedTitle: string,
|
||||||
logbookKey: ArrayBuffer,
|
logbookKey: ArrayBuffer,
|
||||||
payloads: LogbookBackupFile['payloads']
|
manifest: BackupManifestV2,
|
||||||
|
files: Record<string, Uint8Array>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const masterKey = getActiveMasterKey()
|
const masterKey = getActiveMasterKey()
|
||||||
if (!masterKey) throw new Error('Master key not found')
|
if (!masterKey) throw new Error('Master key not found')
|
||||||
@@ -276,78 +149,123 @@ async function queueRestoredLogbookForSync(
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
if (payloads.yacht) {
|
const readFields = (path: string | null) => {
|
||||||
|
if (!path) return null
|
||||||
|
return dexieFieldsFromEncBytes(readBinaryFile(files, path))
|
||||||
|
}
|
||||||
|
|
||||||
|
const yacht = readFields(manifest.files.yacht)
|
||||||
|
if (yacht) {
|
||||||
items.push({
|
items.push({
|
||||||
action: 'update',
|
action: 'update',
|
||||||
type: 'yacht',
|
type: 'yacht',
|
||||||
payloadId: logbookId,
|
payloadId: logbookId,
|
||||||
logbookId,
|
logbookId,
|
||||||
data: encryptedPayloadData(
|
data: encryptedPayloadData(yacht.encryptedData, yacht.iv, yacht.tag),
|
||||||
payloads.yacht.encryptedData,
|
updatedAt: now
|
||||||
payloads.yacht.iv,
|
|
||||||
payloads.yacht.tag
|
|
||||||
),
|
|
||||||
updatedAt: payloads.yacht.updatedAt
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payloads.deviation) {
|
const deviation = readFields(manifest.files.deviation)
|
||||||
|
if (deviation) {
|
||||||
items.push({
|
items.push({
|
||||||
action: 'update',
|
action: 'update',
|
||||||
type: 'deviation',
|
type: 'deviation',
|
||||||
payloadId: logbookId,
|
payloadId: logbookId,
|
||||||
logbookId,
|
logbookId,
|
||||||
data: encryptedPayloadData(
|
data: encryptedPayloadData(deviation.encryptedData, deviation.iv, deviation.tag),
|
||||||
payloads.deviation.encryptedData,
|
updatedAt: now
|
||||||
payloads.deviation.iv,
|
|
||||||
payloads.deviation.tag
|
|
||||||
),
|
|
||||||
updatedAt: payloads.deviation.updatedAt
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const crew of payloads.crews) {
|
const logbookCrew = readFields(manifest.files.logbookCrewSelection)
|
||||||
|
if (logbookCrew) {
|
||||||
|
items.push({
|
||||||
|
action: 'update',
|
||||||
|
type: 'logbookCrew',
|
||||||
|
payloadId: logbookId,
|
||||||
|
logbookId,
|
||||||
|
data: encryptedPayloadData(logbookCrew.encryptedData, logbookCrew.iv, logbookCrew.tag),
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const logbookVessel = readFields(manifest.files.logbookVesselSelection)
|
||||||
|
if (logbookVessel) {
|
||||||
|
items.push({
|
||||||
|
action: 'update',
|
||||||
|
type: 'logbookVessel',
|
||||||
|
payloadId: logbookId,
|
||||||
|
logbookId,
|
||||||
|
data: encryptedPayloadData(
|
||||||
|
logbookVessel.encryptedData,
|
||||||
|
logbookVessel.iv,
|
||||||
|
logbookVessel.tag
|
||||||
|
),
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const crew of manifest.files.crews) {
|
||||||
|
const f = readFields(crew.path)
|
||||||
items.push({
|
items.push({
|
||||||
action: 'create',
|
action: 'create',
|
||||||
type: 'crew',
|
type: 'crew',
|
||||||
payloadId: crew.payloadId,
|
payloadId: crew.payloadId,
|
||||||
logbookId,
|
logbookId,
|
||||||
data: encryptedPayloadData(crew.encryptedData, crew.iv, crew.tag),
|
data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag),
|
||||||
updatedAt: crew.updatedAt
|
updatedAt: crew.updatedAt
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const entry of payloads.entries) {
|
for (const entry of manifest.files.entries) {
|
||||||
|
const f = readFields(entry.path)
|
||||||
items.push({
|
items.push({
|
||||||
action: 'create',
|
action: 'create',
|
||||||
type: 'entry',
|
type: 'entry',
|
||||||
payloadId: entry.payloadId,
|
payloadId: entry.payloadId,
|
||||||
logbookId,
|
logbookId,
|
||||||
data: encryptedPayloadData(entry.encryptedData, entry.iv, entry.tag),
|
data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag),
|
||||||
updatedAt: entry.updatedAt
|
updatedAt: entry.updatedAt
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const photo of payloads.photos) {
|
for (const photo of manifest.files.photos) {
|
||||||
|
const f = readFields(photo.path)
|
||||||
items.push({
|
items.push({
|
||||||
action: 'create',
|
action: 'create',
|
||||||
type: 'photo',
|
type: 'photo',
|
||||||
payloadId: photo.payloadId,
|
payloadId: photo.payloadId,
|
||||||
logbookId,
|
logbookId,
|
||||||
data: encryptedPayloadData(photo.encryptedData, photo.iv, photo.tag, {
|
data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag, {
|
||||||
entryId: photo.entryId
|
entryId: photo.entryId
|
||||||
}),
|
}),
|
||||||
updatedAt: photo.updatedAt
|
updatedAt: photo.updatedAt
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const track of payloads.gpsTracks) {
|
for (const voice of manifest.files.voiceMemos) {
|
||||||
|
const f = readFields(voice.path)
|
||||||
|
items.push({
|
||||||
|
action: 'create',
|
||||||
|
type: 'voiceMemo',
|
||||||
|
payloadId: voice.payloadId,
|
||||||
|
logbookId,
|
||||||
|
data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag, {
|
||||||
|
entryId: voice.entryId
|
||||||
|
}),
|
||||||
|
updatedAt: voice.updatedAt
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const track of manifest.files.gpsTracks) {
|
||||||
|
const f = readFields(track.path)
|
||||||
items.push({
|
items.push({
|
||||||
action: 'create',
|
action: 'create',
|
||||||
type: 'gpsTrack',
|
type: 'gpsTrack',
|
||||||
payloadId: track.entryId,
|
payloadId: track.entryId,
|
||||||
logbookId,
|
logbookId,
|
||||||
data: encryptedPayloadData(track.encryptedData, track.iv, track.tag),
|
data: encryptedPayloadData(f!.encryptedData, f!.iv, f!.tag),
|
||||||
updatedAt: track.updatedAt
|
updatedAt: track.updatedAt
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -357,101 +275,190 @@ async function queueRestoredLogbookForSync(
|
|||||||
|
|
||||||
async function writeBackupToDexie(
|
async function writeBackupToDexie(
|
||||||
logbookId: string,
|
logbookId: string,
|
||||||
backup: LogbookBackupFile,
|
logbookMeta: LogbookMetaJson,
|
||||||
logbookKey: ArrayBuffer
|
logbookKey: ArrayBuffer,
|
||||||
|
manifest: BackupManifestV2,
|
||||||
|
files: Record<string, Uint8Array>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { logbook, payloads } = backup
|
|
||||||
|
|
||||||
await db.logbooks.put({
|
await db.logbooks.put({
|
||||||
id: logbookId,
|
id: logbookId,
|
||||||
encryptedTitle: logbook.encryptedTitle,
|
encryptedTitle: logbookMeta.encryptedTitle,
|
||||||
updatedAt: logbook.updatedAt,
|
updatedAt: logbookMeta.updatedAt,
|
||||||
isSynced: 0,
|
isSynced: 0,
|
||||||
isShared: 0,
|
isShared: 0,
|
||||||
isDemo: logbook.isDemo ? 1 : 0
|
isDemo: logbookMeta.isDemo ? 1 : 0
|
||||||
})
|
})
|
||||||
|
|
||||||
await saveLogbookKey(logbookId, logbookKey)
|
await saveLogbookKey(logbookId, logbookKey)
|
||||||
|
|
||||||
if (payloads.yacht) {
|
const readFields = (path: string | null) => {
|
||||||
|
if (!path) return null
|
||||||
|
return dexieFieldsFromEncBytes(readBinaryFile(files, path))
|
||||||
|
}
|
||||||
|
|
||||||
|
const yacht = readFields(manifest.files.yacht)
|
||||||
|
if (yacht) {
|
||||||
await db.yachts.put({
|
await db.yachts.put({
|
||||||
logbookId,
|
logbookId,
|
||||||
encryptedData: payloads.yacht.encryptedData,
|
encryptedData: yacht.encryptedData,
|
||||||
iv: payloads.yacht.iv,
|
iv: yacht.iv,
|
||||||
tag: payloads.yacht.tag,
|
tag: yacht.tag,
|
||||||
updatedAt: payloads.yacht.updatedAt
|
updatedAt: logbookMeta.updatedAt
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payloads.deviation) {
|
const deviation = readFields(manifest.files.deviation)
|
||||||
|
if (deviation) {
|
||||||
await db.deviations.put({
|
await db.deviations.put({
|
||||||
logbookId,
|
logbookId,
|
||||||
encryptedData: payloads.deviation.encryptedData,
|
encryptedData: deviation.encryptedData,
|
||||||
iv: payloads.deviation.iv,
|
iv: deviation.iv,
|
||||||
tag: payloads.deviation.tag,
|
tag: deviation.tag,
|
||||||
updatedAt: payloads.deviation.updatedAt
|
updatedAt: logbookMeta.updatedAt
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payloads.crews.length > 0) {
|
const logbookCrew = readFields(manifest.files.logbookCrewSelection)
|
||||||
|
if (logbookCrew) {
|
||||||
|
await db.logbookCrewSelections.put({
|
||||||
|
logbookId,
|
||||||
|
encryptedData: logbookCrew.encryptedData,
|
||||||
|
iv: logbookCrew.iv,
|
||||||
|
tag: logbookCrew.tag,
|
||||||
|
updatedAt: logbookMeta.updatedAt
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const logbookVessel = readFields(manifest.files.logbookVesselSelection)
|
||||||
|
if (logbookVessel) {
|
||||||
|
await db.logbookVesselSelections.put({
|
||||||
|
logbookId,
|
||||||
|
encryptedData: logbookVessel.encryptedData,
|
||||||
|
iv: logbookVessel.iv,
|
||||||
|
tag: logbookVessel.tag,
|
||||||
|
updatedAt: logbookMeta.updatedAt
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manifest.files.crews.length > 0) {
|
||||||
await db.crews.bulkPut(
|
await db.crews.bulkPut(
|
||||||
payloads.crews.map((c) => ({
|
manifest.files.crews.map((c) => {
|
||||||
payloadId: c.payloadId,
|
const f = dexieFieldsFromEncBytes(readBinaryFile(files, c.path))
|
||||||
logbookId,
|
return {
|
||||||
encryptedData: c.encryptedData,
|
payloadId: c.payloadId,
|
||||||
iv: c.iv,
|
logbookId,
|
||||||
tag: c.tag,
|
encryptedData: f.encryptedData,
|
||||||
updatedAt: c.updatedAt
|
iv: f.iv,
|
||||||
}))
|
tag: f.tag,
|
||||||
|
updatedAt: c.updatedAt
|
||||||
|
}
|
||||||
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payloads.entries.length > 0) {
|
if (manifest.files.entries.length > 0) {
|
||||||
await db.entries.bulkPut(
|
await db.entries.bulkPut(
|
||||||
payloads.entries.map((e) => ({
|
manifest.files.entries.map((e) => {
|
||||||
payloadId: e.payloadId,
|
const f = dexieFieldsFromEncBytes(readBinaryFile(files, e.path))
|
||||||
logbookId,
|
return {
|
||||||
encryptedData: e.encryptedData,
|
payloadId: e.payloadId,
|
||||||
iv: e.iv,
|
logbookId,
|
||||||
tag: e.tag,
|
encryptedData: f.encryptedData,
|
||||||
updatedAt: e.updatedAt
|
iv: f.iv,
|
||||||
}))
|
tag: f.tag,
|
||||||
|
updatedAt: e.updatedAt
|
||||||
|
}
|
||||||
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payloads.photos.length > 0) {
|
if (manifest.files.photos.length > 0) {
|
||||||
await db.photos.bulkPut(
|
await db.photos.bulkPut(
|
||||||
payloads.photos.map((p) => ({
|
manifest.files.photos.map((p) => {
|
||||||
payloadId: p.payloadId,
|
const f = dexieFieldsFromEncBytes(readBinaryFile(files, p.path))
|
||||||
entryId: p.entryId,
|
return {
|
||||||
logbookId,
|
payloadId: p.payloadId,
|
||||||
encryptedData: p.encryptedData,
|
entryId: p.entryId,
|
||||||
iv: p.iv,
|
logbookId,
|
||||||
tag: p.tag,
|
encryptedData: f.encryptedData,
|
||||||
caption: '',
|
iv: f.iv,
|
||||||
updatedAt: p.updatedAt
|
tag: f.tag,
|
||||||
}))
|
caption: '',
|
||||||
|
updatedAt: p.updatedAt
|
||||||
|
}
|
||||||
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payloads.gpsTracks.length > 0) {
|
if (manifest.files.voiceMemos.length > 0) {
|
||||||
await db.gpsTracks.bulkPut(
|
await db.voiceMemos.bulkPut(
|
||||||
payloads.gpsTracks.map((t) => ({
|
manifest.files.voiceMemos.map((v) => {
|
||||||
entryId: t.entryId,
|
const f = dexieFieldsFromEncBytes(readBinaryFile(files, v.path))
|
||||||
logbookId,
|
return {
|
||||||
encryptedData: t.encryptedData,
|
payloadId: v.payloadId,
|
||||||
iv: t.iv,
|
entryId: v.entryId,
|
||||||
tag: t.tag,
|
logbookId,
|
||||||
updatedAt: t.updatedAt
|
encryptedData: f.encryptedData,
|
||||||
}))
|
iv: f.iv,
|
||||||
|
tag: f.tag,
|
||||||
|
updatedAt: v.updatedAt
|
||||||
|
}
|
||||||
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (manifest.files.gpsTracks.length > 0) {
|
||||||
|
await db.gpsTracks.bulkPut(
|
||||||
|
manifest.files.gpsTracks.map((t) => {
|
||||||
|
const f = dexieFieldsFromEncBytes(readBinaryFile(files, t.path))
|
||||||
|
return {
|
||||||
|
entryId: t.entryId,
|
||||||
|
logbookId,
|
||||||
|
encryptedData: f.encryptedData,
|
||||||
|
iv: f.iv,
|
||||||
|
tag: f.tag,
|
||||||
|
updatedAt: t.updatedAt
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manifest.files.nmeaArchives.length > 0) {
|
||||||
|
await db.nmeaArchives.bulkPut(
|
||||||
|
manifest.files.nmeaArchives.map((n) => {
|
||||||
|
const f = dexieFieldsFromEncBytes(readBinaryFile(files, n.path))
|
||||||
|
return {
|
||||||
|
entryId: n.entryId,
|
||||||
|
logbookId,
|
||||||
|
encryptedData: f.encryptedData,
|
||||||
|
iv: f.iv,
|
||||||
|
tag: f.tag,
|
||||||
|
updatedAt: n.updatedAt
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function remapParsedBackup(
|
||||||
|
parsed: ParsedLogbookBackup,
|
||||||
|
newLogbookId: string
|
||||||
|
): ParsedLogbookBackup {
|
||||||
|
const logbookMeta = JSON.parse(readTextFile(parsed.files, parsed.manifest.files.logbook)) as LogbookMetaJson
|
||||||
|
logbookMeta.id = newLogbookId
|
||||||
|
const newFiles = { ...parsed.files }
|
||||||
|
newFiles[parsed.manifest.files.logbook] = new TextEncoder().encode(JSON.stringify(logbookMeta))
|
||||||
|
return {
|
||||||
|
manifest: { ...parsed.manifest, logbookId: newLogbookId },
|
||||||
|
files: newFiles
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function exportLogbookBackup(
|
export async function exportLogbookBackup(
|
||||||
logbookId: string,
|
logbookId: string,
|
||||||
passphrase: string
|
passphrase: string,
|
||||||
): Promise<{ blob: Blob; filename: string; backup: LogbookBackupFile }> {
|
options: ExportLogbookBackupOptions = {}
|
||||||
|
): Promise<{ blob: Blob; filename: string; manifest: BackupManifestV2 }> {
|
||||||
if (!passphrase.trim() || passphrase.length < 8) {
|
if (!passphrase.trim() || passphrase.length < 8) {
|
||||||
throw new Error('BACKUP_PASSPHRASE_TOO_SHORT')
|
throw new Error('BACKUP_PASSPHRASE_TOO_SHORT')
|
||||||
}
|
}
|
||||||
@@ -467,70 +474,84 @@ export async function exportLogbookBackup(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
options.onProgress?.({ phase: 'collect', current: 0, total: 1, bytesPacked: 0 })
|
||||||
|
const collected = await collectLogbookBackupData(logbookId)
|
||||||
const logbookKey = (await getLogbookKey(logbookId)) ?? (await ensureLogbookKey(logbookId))
|
const logbookKey = (await getLogbookKey(logbookId)) ?? (await ensureLogbookKey(logbookId))
|
||||||
const payloads = await collectLogbookPayloads(logbookId)
|
const wrapped = await wrapLogbookKey(logbookKey, passphrase)
|
||||||
const wrappedKey = await wrapLogbookKey(logbookKey, passphrase)
|
const keyEnc = encBytesFromDexieFields({
|
||||||
|
encryptedData: wrapped.ciphertext,
|
||||||
|
iv: wrapped.iv,
|
||||||
|
tag: wrapped.tag
|
||||||
|
})
|
||||||
|
|
||||||
const backup: LogbookBackupFile = {
|
const { zipBytes, manifest } = buildArchiveFromCollected(collected, keyEnc, {
|
||||||
format: BACKUP_FORMAT,
|
|
||||||
version: BACKUP_VERSION,
|
|
||||||
exportedAt: new Date().toISOString(),
|
exportedAt: new Date().toISOString(),
|
||||||
logbook: {
|
appVersion: getAppVersion(),
|
||||||
id: logbook.id,
|
onProgress: options.onProgress
|
||||||
encryptedTitle: logbook.encryptedTitle,
|
})
|
||||||
updatedAt: logbook.updatedAt,
|
|
||||||
isDemo: logbook.isDemo === 1
|
|
||||||
},
|
|
||||||
logbookKey: wrappedKey,
|
|
||||||
payloads,
|
|
||||||
counts: {
|
|
||||||
entries: payloads.entries.length,
|
|
||||||
photos: payloads.photos.length,
|
|
||||||
crews: payloads.crews.length,
|
|
||||||
gpsTracks: payloads.gpsTracks.length,
|
|
||||||
hasYacht: !!payloads.yacht,
|
|
||||||
hasDeviation: !!payloads.deviation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const title = await decryptLogbookTitle(logbookId, logbook.encryptedTitle)
|
const title = await decryptLogbookTitle(logbookId, logbook.encryptedTitle)
|
||||||
const safeTitle = title.replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-').slice(0, 40) || 'logbook'
|
const safeTitle = title.replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-').slice(0, 40) || 'logbook'
|
||||||
const datePart = new Date().toISOString().slice(0, 10)
|
const datePart = new Date().toISOString().slice(0, 10)
|
||||||
const filename = `${safeTitle}-${datePart}.daagbok.json`
|
const filename = `${safeTitle}-${datePart}.daagbok`
|
||||||
const blob = new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' })
|
const blob = new Blob([zipBytes.slice()], { type: 'application/zip' })
|
||||||
|
|
||||||
return { blob, filename, backup }
|
return { blob, filename, manifest }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function parseLogbookBackupFile(file: File): Promise<LogbookBackupFile> {
|
function detectLegacyJsonV1(text: string): boolean {
|
||||||
const text = await file.text()
|
const trimmed = text.trimStart()
|
||||||
let parsed: unknown
|
if (!trimmed.startsWith('{')) return false
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(text)
|
const parsed = JSON.parse(trimmed) as { format?: string; version?: number }
|
||||||
|
return parsed.format === BACKUP_FORMAT && parsed.version === 1
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error('BACKUP_INVALID_JSON')
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseLogbookBackupFile(file: File): Promise<ParsedLogbookBackup> {
|
||||||
|
const buffer = await file.arrayBuffer()
|
||||||
|
const bytes = new Uint8Array(buffer)
|
||||||
|
|
||||||
|
if (!isZipArchive(bytes)) {
|
||||||
|
const text = new TextDecoder().decode(bytes)
|
||||||
|
if (detectLegacyJsonV1(text)) {
|
||||||
|
throw new Error('BACKUP_VERSION_UNSUPPORTED')
|
||||||
|
}
|
||||||
|
throw new Error('BACKUP_INVALID_ARCHIVE')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isBackupFile(parsed)) {
|
const files = unzipArchive(bytes)
|
||||||
throw new Error('BACKUP_INVALID_FORMAT')
|
const manifest = readManifestFromArchive(files)
|
||||||
}
|
return { manifest, files }
|
||||||
|
|
||||||
return parsed
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function previewLogbookBackup(
|
export async function previewLogbookBackup(
|
||||||
backup: LogbookBackupFile,
|
backup: ParsedLogbookBackup,
|
||||||
passphrase: string
|
passphrase: string
|
||||||
): Promise<LogbookBackupPreview> {
|
): Promise<LogbookBackupPreview> {
|
||||||
const logbookKey = await unwrapLogbookKey(backup.logbookKey, passphrase)
|
const logbookKey = await unwrapLogbookKeyFromEnc(
|
||||||
const parsed = JSON.parse(backup.logbook.encryptedTitle)
|
readBinaryFile(backup.files, backup.manifest.files.key),
|
||||||
const title = await decryptJson(parsed.ciphertext, parsed.iv, parsed.tag, logbookKey)
|
passphrase
|
||||||
|
)
|
||||||
|
const logbookMeta = JSON.parse(
|
||||||
|
readTextFile(backup.files, backup.manifest.files.logbook)
|
||||||
|
) as LogbookMetaJson
|
||||||
|
const parsed = JSON.parse(logbookMeta.encryptedTitle)
|
||||||
|
let title: string
|
||||||
|
try {
|
||||||
|
title = await decryptJson(parsed.ciphertext, parsed.iv, parsed.tag, logbookKey)
|
||||||
|
} catch {
|
||||||
|
throw new Error('BACKUP_WRONG_PASSPHRASE')
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
exportedAt: backup.exportedAt,
|
exportedAt: backup.manifest.exportedAt,
|
||||||
sourceLogbookId: backup.logbook.id,
|
sourceLogbookId: backup.manifest.logbookId,
|
||||||
counts: backup.counts
|
counts: backup.manifest.counts,
|
||||||
|
totalUncompressedBytes: backup.manifest.totalUncompressedBytes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,7 +561,7 @@ export interface RestoreLogbookOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function restoreLogbookBackup(
|
export async function restoreLogbookBackup(
|
||||||
backup: LogbookBackupFile,
|
backup: ParsedLogbookBackup,
|
||||||
passphrase: string,
|
passphrase: string,
|
||||||
options: RestoreLogbookOptions = {}
|
options: RestoreLogbookOptions = {}
|
||||||
): Promise<{ logbookId: string; title: string }> {
|
): Promise<{ logbookId: string; title: string }> {
|
||||||
@@ -548,16 +569,22 @@ export async function restoreLogbookBackup(
|
|||||||
throw new Error('BACKUP_NOT_AUTHENTICATED')
|
throw new Error('BACKUP_NOT_AUTHENTICATED')
|
||||||
}
|
}
|
||||||
|
|
||||||
const logbookKey = await unwrapLogbookKey(backup.logbookKey, passphrase)
|
const logbookKey = await unwrapLogbookKeyFromEnc(
|
||||||
const parsedTitle = JSON.parse(backup.logbook.encryptedTitle)
|
readBinaryFile(backup.files, backup.manifest.files.key),
|
||||||
const title = await decryptJson(
|
passphrase
|
||||||
parsedTitle.ciphertext,
|
|
||||||
parsedTitle.iv,
|
|
||||||
parsedTitle.tag,
|
|
||||||
logbookKey
|
|
||||||
)
|
)
|
||||||
|
const logbookMeta = JSON.parse(
|
||||||
|
readTextFile(backup.files, backup.manifest.files.logbook)
|
||||||
|
) as LogbookMetaJson
|
||||||
|
const parsedTitle = JSON.parse(logbookMeta.encryptedTitle)
|
||||||
|
let title: string
|
||||||
|
try {
|
||||||
|
title = await decryptJson(parsedTitle.ciphertext, parsedTitle.iv, parsedTitle.tag, logbookKey)
|
||||||
|
} catch {
|
||||||
|
throw new Error('BACKUP_WRONG_PASSPHRASE')
|
||||||
|
}
|
||||||
|
|
||||||
let targetId = backup.logbook.id
|
let targetId = backup.manifest.logbookId
|
||||||
const existing = await db.logbooks.get(targetId)
|
const existing = await db.logbooks.get(targetId)
|
||||||
|
|
||||||
if (existing && !options.overwrite && !options.assignNewId) {
|
if (existing && !options.overwrite && !options.assignNewId) {
|
||||||
@@ -568,18 +595,29 @@ export async function restoreLogbookBackup(
|
|||||||
await deleteLocalLogbookCache(targetId)
|
await deleteLocalLogbookCache(targetId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let prepared = backup
|
||||||
if (options.assignNewId || (existing && !options.overwrite)) {
|
if (options.assignNewId || (existing && !options.overwrite)) {
|
||||||
targetId = crypto.randomUUID()
|
targetId = crypto.randomUUID()
|
||||||
|
prepared = remapParsedBackup(backup, targetId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const prepared = targetId === backup.logbook.id ? backup : remapBackup(backup, targetId)
|
const finalMeta = JSON.parse(
|
||||||
|
readTextFile(prepared.files, prepared.manifest.files.logbook)
|
||||||
|
) as LogbookMetaJson
|
||||||
|
|
||||||
await writeBackupToDexie(targetId, prepared, logbookKey)
|
await writeBackupToDexie(
|
||||||
|
targetId,
|
||||||
|
finalMeta,
|
||||||
|
logbookKey,
|
||||||
|
prepared.manifest,
|
||||||
|
prepared.files
|
||||||
|
)
|
||||||
await queueRestoredLogbookForSync(
|
await queueRestoredLogbookForSync(
|
||||||
targetId,
|
targetId,
|
||||||
prepared.logbook.encryptedTitle,
|
finalMeta.encryptedTitle,
|
||||||
logbookKey,
|
logbookKey,
|
||||||
prepared.payloads
|
prepared.manifest,
|
||||||
|
prepared.files
|
||||||
)
|
)
|
||||||
|
|
||||||
if (navigator.onLine) {
|
if (navigator.onLine) {
|
||||||
@@ -599,3 +637,14 @@ export function downloadBackupBlob(blob: Blob, filename: string): void {
|
|||||||
anchor.click()
|
anchor.click()
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Human-readable size for UI warnings. */
|
||||||
|
export function formatBackupBytes(bytes: number): string {
|
||||||
|
const fmt = (n: number) => formatAppDecimal(n, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
if (bytes < 1024 * 1024) return `${fmt(bytes / 1024)} KB`
|
||||||
|
return `${fmt(bytes / (1024 * 1024))} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BACKUP_SIZE_WARN_BYTES = 50_000_000
|
||||||
|
export const BACKUP_SIZE_CONFIRM_BYTES = 150_000_000
|
||||||
|
|||||||
@@ -0,0 +1,355 @@
|
|||||||
|
import { db } from '../db.js'
|
||||||
|
import { encBytesFromDexieFields, type DexieEncFields } from './encBlob.js'
|
||||||
|
import { buildZipArchive, utf8Bytes } from './zipArchive.js'
|
||||||
|
import {
|
||||||
|
BACKUP_FORMAT,
|
||||||
|
BACKUP_VERSION,
|
||||||
|
type BackupIndexedEntryFile,
|
||||||
|
type BackupIndexedPayloadFile,
|
||||||
|
type BackupIndexedTrackFile,
|
||||||
|
type BackupManifestCounts,
|
||||||
|
type BackupManifestFiles,
|
||||||
|
type BackupManifestV2,
|
||||||
|
type LogbookMetaJson
|
||||||
|
} from './manifest.js'
|
||||||
|
|
||||||
|
export interface CollectedBackupData {
|
||||||
|
logbookMeta: LogbookMetaJson
|
||||||
|
yacht: DexieEncFields | null
|
||||||
|
deviation: DexieEncFields | null
|
||||||
|
logbookCrewSelection: DexieEncFields | null
|
||||||
|
logbookVesselSelection: DexieEncFields | null
|
||||||
|
crews: Array<DexieEncFields & { payloadId: string; updatedAt: string }>
|
||||||
|
entries: Array<DexieEncFields & { payloadId: string; updatedAt: string }>
|
||||||
|
photos: Array<DexieEncFields & { payloadId: string; entryId: string; updatedAt: string }>
|
||||||
|
voiceMemos: Array<DexieEncFields & { payloadId: string; entryId: string; updatedAt: string }>
|
||||||
|
gpsTracks: Array<DexieEncFields & { entryId: string; updatedAt: string }>
|
||||||
|
nmeaArchives: Array<DexieEncFields & { entryId: string; updatedAt: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickEnc(row: {
|
||||||
|
encryptedData: string
|
||||||
|
iv: string
|
||||||
|
tag: string
|
||||||
|
}): DexieEncFields {
|
||||||
|
return {
|
||||||
|
encryptedData: row.encryptedData,
|
||||||
|
iv: row.iv,
|
||||||
|
tag: row.tag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function collectLogbookBackupData(
|
||||||
|
logbookId: string
|
||||||
|
): Promise<CollectedBackupData> {
|
||||||
|
const [
|
||||||
|
logbook,
|
||||||
|
yacht,
|
||||||
|
deviation,
|
||||||
|
logbookCrewSelection,
|
||||||
|
logbookVesselSelection,
|
||||||
|
crews,
|
||||||
|
entries,
|
||||||
|
photos,
|
||||||
|
voiceMemos,
|
||||||
|
gpsTracks,
|
||||||
|
nmeaArchives
|
||||||
|
] = await Promise.all([
|
||||||
|
db.logbooks.get(logbookId),
|
||||||
|
db.yachts.get(logbookId),
|
||||||
|
db.deviations.get(logbookId),
|
||||||
|
db.logbookCrewSelections.get(logbookId),
|
||||||
|
db.logbookVesselSelections.get(logbookId),
|
||||||
|
db.crews.where({ logbookId }).toArray(),
|
||||||
|
db.entries.where({ logbookId }).toArray(),
|
||||||
|
db.photos.where({ logbookId }).toArray(),
|
||||||
|
db.voiceMemos.where({ logbookId }).toArray(),
|
||||||
|
db.gpsTracks.where({ logbookId }).toArray(),
|
||||||
|
db.nmeaArchives.where({ logbookId }).toArray()
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!logbook) throw new Error('BACKUP_LOGBOOK_NOT_FOUND')
|
||||||
|
|
||||||
|
return {
|
||||||
|
logbookMeta: {
|
||||||
|
id: logbook.id,
|
||||||
|
encryptedTitle: logbook.encryptedTitle,
|
||||||
|
updatedAt: logbook.updatedAt,
|
||||||
|
isDemo: logbook.isDemo === 1
|
||||||
|
},
|
||||||
|
yacht: yacht ? pickEnc(yacht) : null,
|
||||||
|
deviation: deviation ? pickEnc(deviation) : null,
|
||||||
|
logbookCrewSelection: logbookCrewSelection ? pickEnc(logbookCrewSelection) : null,
|
||||||
|
logbookVesselSelection: logbookVesselSelection ? pickEnc(logbookVesselSelection) : null,
|
||||||
|
crews: crews.map((c) => ({ ...pickEnc(c), payloadId: c.payloadId, updatedAt: c.updatedAt })),
|
||||||
|
entries: entries.map((e) => ({
|
||||||
|
...pickEnc(e),
|
||||||
|
payloadId: e.payloadId,
|
||||||
|
updatedAt: e.updatedAt
|
||||||
|
})),
|
||||||
|
photos: photos.map((p) => ({
|
||||||
|
...pickEnc(p),
|
||||||
|
payloadId: p.payloadId,
|
||||||
|
entryId: p.entryId,
|
||||||
|
updatedAt: p.updatedAt
|
||||||
|
})),
|
||||||
|
voiceMemos: voiceMemos.map((v) => ({
|
||||||
|
...pickEnc(v),
|
||||||
|
payloadId: v.payloadId,
|
||||||
|
entryId: v.entryId,
|
||||||
|
updatedAt: v.updatedAt
|
||||||
|
})),
|
||||||
|
gpsTracks: gpsTracks.map((t) => ({
|
||||||
|
...pickEnc(t),
|
||||||
|
entryId: t.entryId,
|
||||||
|
updatedAt: t.updatedAt
|
||||||
|
})),
|
||||||
|
nmeaArchives: nmeaArchives.map((n) => ({
|
||||||
|
...pickEnc(n),
|
||||||
|
entryId: n.entryId,
|
||||||
|
updatedAt: n.updatedAt
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BackupProgressPhase = 'collect' | 'pack' | 'done'
|
||||||
|
|
||||||
|
export interface BackupExportProgress {
|
||||||
|
phase: BackupProgressPhase
|
||||||
|
current: number
|
||||||
|
total: number
|
||||||
|
bytesPacked: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuiltArchive {
|
||||||
|
zipBytes: Uint8Array
|
||||||
|
manifest: BackupManifestV2
|
||||||
|
counts: BackupManifestCounts
|
||||||
|
totalUncompressedBytes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function addEncFile(
|
||||||
|
zipFiles: Record<string, Uint8Array>,
|
||||||
|
path: string,
|
||||||
|
fields: DexieEncFields
|
||||||
|
): number {
|
||||||
|
const bytes = encBytesFromDexieFields(fields)
|
||||||
|
zipFiles[path] = bytes
|
||||||
|
return bytes.byteLength
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildArchiveFromCollected(
|
||||||
|
collected: CollectedBackupData,
|
||||||
|
keyEnc: Uint8Array,
|
||||||
|
options: {
|
||||||
|
exportedAt: string
|
||||||
|
appVersion?: string
|
||||||
|
onProgress?: (progress: BackupExportProgress) => void
|
||||||
|
}
|
||||||
|
): BuiltArchive {
|
||||||
|
const zipFiles: Record<string, Uint8Array> = {}
|
||||||
|
let totalUncompressedBytes = 0
|
||||||
|
|
||||||
|
const logbookPath = 'logbook.meta.json'
|
||||||
|
zipFiles[logbookPath] = utf8Bytes(JSON.stringify(collected.logbookMeta))
|
||||||
|
totalUncompressedBytes += zipFiles[logbookPath].byteLength
|
||||||
|
|
||||||
|
zipFiles['key.enc'] = keyEnc
|
||||||
|
totalUncompressedBytes += keyEnc.byteLength
|
||||||
|
|
||||||
|
const files: BackupManifestFiles = {
|
||||||
|
key: 'key.enc',
|
||||||
|
logbook: logbookPath,
|
||||||
|
yacht: null,
|
||||||
|
deviation: null,
|
||||||
|
logbookCrewSelection: null,
|
||||||
|
logbookVesselSelection: null,
|
||||||
|
crews: [],
|
||||||
|
entries: [],
|
||||||
|
photos: [],
|
||||||
|
voiceMemos: [],
|
||||||
|
gpsTracks: [],
|
||||||
|
nmeaArchives: []
|
||||||
|
}
|
||||||
|
|
||||||
|
const packSteps: Array<() => void> = []
|
||||||
|
|
||||||
|
if (collected.yacht) {
|
||||||
|
packSteps.push(() => {
|
||||||
|
const path = 'payloads/yacht.enc'
|
||||||
|
const size = addEncFile(zipFiles, path, collected.yacht!)
|
||||||
|
files.yacht = path
|
||||||
|
totalUncompressedBytes += size
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collected.deviation) {
|
||||||
|
packSteps.push(() => {
|
||||||
|
const path = 'payloads/deviation.enc'
|
||||||
|
const size = addEncFile(zipFiles, path, collected.deviation!)
|
||||||
|
files.deviation = path
|
||||||
|
totalUncompressedBytes += size
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collected.logbookCrewSelection) {
|
||||||
|
packSteps.push(() => {
|
||||||
|
const path = 'payloads/logbook-crew.enc'
|
||||||
|
const size = addEncFile(zipFiles, path, collected.logbookCrewSelection!)
|
||||||
|
files.logbookCrewSelection = path
|
||||||
|
totalUncompressedBytes += size
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collected.logbookVesselSelection) {
|
||||||
|
packSteps.push(() => {
|
||||||
|
const path = 'payloads/logbook-vessel.enc'
|
||||||
|
const size = addEncFile(zipFiles, path, collected.logbookVesselSelection!)
|
||||||
|
files.logbookVesselSelection = path
|
||||||
|
totalUncompressedBytes += size
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const c of collected.crews) {
|
||||||
|
packSteps.push(() => {
|
||||||
|
const path = `payloads/crews/${c.payloadId}.enc`
|
||||||
|
const size = addEncFile(zipFiles, path, c)
|
||||||
|
const index: BackupIndexedPayloadFile = {
|
||||||
|
path,
|
||||||
|
payloadId: c.payloadId,
|
||||||
|
updatedAt: c.updatedAt,
|
||||||
|
bytes: size
|
||||||
|
}
|
||||||
|
files.crews.push(index)
|
||||||
|
totalUncompressedBytes += size
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const e of collected.entries) {
|
||||||
|
packSteps.push(() => {
|
||||||
|
const path = `payloads/entries/${e.payloadId}.enc`
|
||||||
|
const size = addEncFile(zipFiles, path, e)
|
||||||
|
const index: BackupIndexedPayloadFile = {
|
||||||
|
path,
|
||||||
|
payloadId: e.payloadId,
|
||||||
|
updatedAt: e.updatedAt,
|
||||||
|
bytes: size
|
||||||
|
}
|
||||||
|
files.entries.push(index)
|
||||||
|
totalUncompressedBytes += size
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const p of collected.photos) {
|
||||||
|
packSteps.push(() => {
|
||||||
|
const path = `payloads/photos/${p.payloadId}.enc`
|
||||||
|
const size = addEncFile(zipFiles, path, p)
|
||||||
|
const index: BackupIndexedEntryFile = {
|
||||||
|
path,
|
||||||
|
payloadId: p.payloadId,
|
||||||
|
entryId: p.entryId,
|
||||||
|
updatedAt: p.updatedAt,
|
||||||
|
bytes: size
|
||||||
|
}
|
||||||
|
files.photos.push(index)
|
||||||
|
totalUncompressedBytes += size
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const v of collected.voiceMemos) {
|
||||||
|
packSteps.push(() => {
|
||||||
|
const path = `payloads/voice-memos/${v.payloadId}.enc`
|
||||||
|
const size = addEncFile(zipFiles, path, v)
|
||||||
|
const index: BackupIndexedEntryFile = {
|
||||||
|
path,
|
||||||
|
payloadId: v.payloadId,
|
||||||
|
entryId: v.entryId,
|
||||||
|
updatedAt: v.updatedAt,
|
||||||
|
bytes: size
|
||||||
|
}
|
||||||
|
files.voiceMemos.push(index)
|
||||||
|
totalUncompressedBytes += size
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const t of collected.gpsTracks) {
|
||||||
|
packSteps.push(() => {
|
||||||
|
const path = `payloads/gps-tracks/${t.entryId}.enc`
|
||||||
|
const size = addEncFile(zipFiles, path, t)
|
||||||
|
const index: BackupIndexedTrackFile = {
|
||||||
|
path,
|
||||||
|
entryId: t.entryId,
|
||||||
|
updatedAt: t.updatedAt,
|
||||||
|
bytes: size
|
||||||
|
}
|
||||||
|
files.gpsTracks.push(index)
|
||||||
|
totalUncompressedBytes += size
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const n of collected.nmeaArchives) {
|
||||||
|
packSteps.push(() => {
|
||||||
|
const path = `payloads/nmea-archives/${n.entryId}.enc`
|
||||||
|
const size = addEncFile(zipFiles, path, n)
|
||||||
|
const index: BackupIndexedTrackFile = {
|
||||||
|
path,
|
||||||
|
entryId: n.entryId,
|
||||||
|
updatedAt: n.updatedAt,
|
||||||
|
bytes: size
|
||||||
|
}
|
||||||
|
files.nmeaArchives.push(index)
|
||||||
|
totalUncompressedBytes += size
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = packSteps.length
|
||||||
|
packSteps.forEach((step, i) => {
|
||||||
|
step()
|
||||||
|
options.onProgress?.({
|
||||||
|
phase: 'pack',
|
||||||
|
current: i + 1,
|
||||||
|
total,
|
||||||
|
bytesPacked: totalUncompressedBytes
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const counts: BackupManifestCounts = {
|
||||||
|
entries: collected.entries.length,
|
||||||
|
photos: collected.photos.length,
|
||||||
|
voiceMemos: collected.voiceMemos.length,
|
||||||
|
crews: collected.crews.length,
|
||||||
|
gpsTracks: collected.gpsTracks.length,
|
||||||
|
nmeaArchives: collected.nmeaArchives.length,
|
||||||
|
hasYacht: !!collected.yacht,
|
||||||
|
hasDeviation: !!collected.deviation,
|
||||||
|
hasLogbookCrewSelection: !!collected.logbookCrewSelection,
|
||||||
|
hasLogbookVesselSelection: !!collected.logbookVesselSelection
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifest: BackupManifestV2 = {
|
||||||
|
format: BACKUP_FORMAT,
|
||||||
|
version: BACKUP_VERSION,
|
||||||
|
exportedAt: options.exportedAt,
|
||||||
|
appVersion: options.appVersion,
|
||||||
|
compression: 'zip-deflate-6',
|
||||||
|
logbookId: collected.logbookMeta.id,
|
||||||
|
counts,
|
||||||
|
totalUncompressedBytes,
|
||||||
|
files
|
||||||
|
}
|
||||||
|
|
||||||
|
zipFiles['manifest.json'] = utf8Bytes(JSON.stringify(manifest))
|
||||||
|
totalUncompressedBytes += zipFiles['manifest.json'].byteLength
|
||||||
|
|
||||||
|
const zipBytes = buildZipArchive(zipFiles)
|
||||||
|
manifest.totalUncompressedBytes = totalUncompressedBytes
|
||||||
|
|
||||||
|
options.onProgress?.({
|
||||||
|
phase: 'done',
|
||||||
|
current: total,
|
||||||
|
total,
|
||||||
|
bytesPacked: totalUncompressedBytes
|
||||||
|
})
|
||||||
|
|
||||||
|
return { zipBytes, manifest, counts, totalUncompressedBytes }
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
dexieFieldsFromEncBytes,
|
||||||
|
encBytesFromDexieFields,
|
||||||
|
ENC_HEADER_SIZE
|
||||||
|
} from './encBlob.js'
|
||||||
|
|
||||||
|
function toB64(bytes: number[]): string {
|
||||||
|
return btoa(String.fromCharCode(...bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('encBlob', () => {
|
||||||
|
it('round-trips dexie AES-GCM fields', () => {
|
||||||
|
const fields = {
|
||||||
|
encryptedData: toB64([9, 8, 7]),
|
||||||
|
iv: toB64(Array.from({ length: 12 }, (_, i) => i)),
|
||||||
|
tag: toB64(Array.from({ length: 16 }, (_, i) => i + 20))
|
||||||
|
}
|
||||||
|
const enc = encBytesFromDexieFields(fields)
|
||||||
|
expect(enc.byteLength).toBe(ENC_HEADER_SIZE + 3)
|
||||||
|
expect(dexieFieldsFromEncBytes(enc)).toEqual(fields)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects invalid magic', () => {
|
||||||
|
expect(() => dexieFieldsFromEncBytes(new Uint8Array(40))).toThrow('BACKUP_INVALID_ENC')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { base64ToBuffer, bufferToBase64 } from '../crypto.js'
|
||||||
|
|
||||||
|
export const ENC_MAGIC = new Uint8Array([0x4b, 0x44, 0x41, 0x42]) // KDAB
|
||||||
|
export const ENC_FORMAT_VERSION = 1
|
||||||
|
export const ENC_HEADER_SIZE = 33 // 4 + 1 + 12 + 16
|
||||||
|
|
||||||
|
export interface DexieEncFields {
|
||||||
|
encryptedData: string
|
||||||
|
iv: string
|
||||||
|
tag: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encBytesFromDexieFields(fields: DexieEncFields): Uint8Array {
|
||||||
|
const iv = new Uint8Array(base64ToBuffer(fields.iv))
|
||||||
|
const tag = new Uint8Array(base64ToBuffer(fields.tag))
|
||||||
|
const ciphertext = new Uint8Array(base64ToBuffer(fields.encryptedData))
|
||||||
|
if (iv.length !== 12) throw new Error('BACKUP_INVALID_ENC')
|
||||||
|
if (tag.length !== 16) throw new Error('BACKUP_INVALID_ENC')
|
||||||
|
|
||||||
|
const out = new Uint8Array(ENC_HEADER_SIZE + ciphertext.length)
|
||||||
|
out.set(ENC_MAGIC, 0)
|
||||||
|
out[4] = ENC_FORMAT_VERSION
|
||||||
|
out.set(iv, 5)
|
||||||
|
out.set(tag, 17)
|
||||||
|
out.set(ciphertext, 33)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dexieFieldsFromEncBytes(bytes: Uint8Array): DexieEncFields {
|
||||||
|
if (bytes.length < ENC_HEADER_SIZE) throw new Error('BACKUP_INVALID_ENC')
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
if (bytes[i] !== ENC_MAGIC[i]) throw new Error('BACKUP_INVALID_ENC')
|
||||||
|
}
|
||||||
|
if (bytes[4] !== ENC_FORMAT_VERSION) throw new Error('BACKUP_INVALID_ENC')
|
||||||
|
|
||||||
|
const iv = bufferToBase64(bytes.slice(5, 17).buffer)
|
||||||
|
const tag = bufferToBase64(bytes.slice(17, 33).buffer)
|
||||||
|
const ciphertext = bufferToBase64(bytes.slice(33).buffer)
|
||||||
|
return { encryptedData: ciphertext, iv, tag }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encByteLength(fields: DexieEncFields): number {
|
||||||
|
const ct = base64ToBuffer(fields.encryptedData).byteLength
|
||||||
|
return ENC_HEADER_SIZE + ct
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
export const BACKUP_FORMAT = 'kapteins-daagbok-backup' as const
|
||||||
|
export const BACKUP_VERSION = 2 as const
|
||||||
|
|
||||||
|
export interface BackupIndexedFile {
|
||||||
|
path: string
|
||||||
|
updatedAt: string
|
||||||
|
bytes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupIndexedPayloadFile extends BackupIndexedFile {
|
||||||
|
payloadId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupIndexedEntryFile extends BackupIndexedPayloadFile {
|
||||||
|
entryId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupIndexedTrackFile extends BackupIndexedFile {
|
||||||
|
entryId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupManifestCounts {
|
||||||
|
entries: number
|
||||||
|
photos: number
|
||||||
|
voiceMemos: number
|
||||||
|
crews: number
|
||||||
|
gpsTracks: number
|
||||||
|
nmeaArchives: number
|
||||||
|
hasYacht: boolean
|
||||||
|
hasDeviation: boolean
|
||||||
|
hasLogbookCrewSelection: boolean
|
||||||
|
hasLogbookVesselSelection: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupManifestFiles {
|
||||||
|
key: string
|
||||||
|
logbook: string
|
||||||
|
yacht: string | null
|
||||||
|
deviation: string | null
|
||||||
|
logbookCrewSelection: string | null
|
||||||
|
logbookVesselSelection: string | null
|
||||||
|
crews: BackupIndexedPayloadFile[]
|
||||||
|
entries: BackupIndexedPayloadFile[]
|
||||||
|
photos: BackupIndexedEntryFile[]
|
||||||
|
voiceMemos: BackupIndexedEntryFile[]
|
||||||
|
gpsTracks: BackupIndexedTrackFile[]
|
||||||
|
nmeaArchives: BackupIndexedTrackFile[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupManifestV2 {
|
||||||
|
format: typeof BACKUP_FORMAT
|
||||||
|
version: typeof BACKUP_VERSION
|
||||||
|
exportedAt: string
|
||||||
|
appVersion?: string
|
||||||
|
compression: 'zip-deflate-6'
|
||||||
|
logbookId: string
|
||||||
|
counts: BackupManifestCounts
|
||||||
|
totalUncompressedBytes: number
|
||||||
|
files: BackupManifestFiles
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogbookMetaJson {
|
||||||
|
id: string
|
||||||
|
encryptedTitle: string
|
||||||
|
updatedAt: string
|
||||||
|
isDemo?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseManifestJson(text: string): BackupManifestV2 {
|
||||||
|
let parsed: unknown
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(text)
|
||||||
|
} catch {
|
||||||
|
throw new Error('BACKUP_INVALID_FORMAT')
|
||||||
|
}
|
||||||
|
if (!isBackupManifestV2(parsed)) {
|
||||||
|
throw new Error('BACKUP_INVALID_FORMAT')
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBackupManifestV2(value: unknown): value is BackupManifestV2 {
|
||||||
|
if (!value || typeof value !== 'object') return false
|
||||||
|
const obj = value as Partial<BackupManifestV2>
|
||||||
|
return (
|
||||||
|
obj.format === BACKUP_FORMAT &&
|
||||||
|
obj.version === BACKUP_VERSION &&
|
||||||
|
typeof obj.exportedAt === 'string' &&
|
||||||
|
typeof obj.logbookId === 'string' &&
|
||||||
|
!!obj.counts &&
|
||||||
|
!!obj.files
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeManifest(manifest: BackupManifestV2): string {
|
||||||
|
return JSON.stringify(manifest)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { strToU8, unzipSync, zipSync } from 'fflate'
|
||||||
|
import { parseManifestJson, type BackupManifestV2 } from './manifest.js'
|
||||||
|
|
||||||
|
const ZIP_LEVEL = 6
|
||||||
|
|
||||||
|
export function buildZipArchive(files: Record<string, Uint8Array>): Uint8Array {
|
||||||
|
return zipSync(files, { level: ZIP_LEVEL })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unzipArchive(data: Uint8Array): Record<string, Uint8Array> {
|
||||||
|
try {
|
||||||
|
return unzipSync(data)
|
||||||
|
} catch {
|
||||||
|
throw new Error('BACKUP_INVALID_ARCHIVE')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readManifestFromArchive(
|
||||||
|
files: Record<string, Uint8Array>
|
||||||
|
): BackupManifestV2 {
|
||||||
|
const raw = files['manifest.json']
|
||||||
|
if (!raw) throw new Error('BACKUP_INVALID_FORMAT')
|
||||||
|
const text = new TextDecoder().decode(raw)
|
||||||
|
return parseManifestJson(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readTextFile(files: Record<string, Uint8Array>, path: string): string {
|
||||||
|
const raw = files[path]
|
||||||
|
if (!raw) throw new Error('BACKUP_MISSING_BLOB')
|
||||||
|
return new TextDecoder().decode(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readBinaryFile(files: Record<string, Uint8Array>, path: string): Uint8Array {
|
||||||
|
const raw = files[path]
|
||||||
|
if (!raw) throw new Error('BACKUP_MISSING_BLOB')
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
export function utf8Bytes(text: string): Uint8Array {
|
||||||
|
return strToU8(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isZipArchive(bytes: Uint8Array): boolean {
|
||||||
|
return bytes.length >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4b
|
||||||
|
}
|
||||||
@@ -91,6 +91,7 @@ export function clearLogbookKeysCache() {
|
|||||||
export async function ensureLogbookKey(logbookId: string): Promise<ArrayBuffer> {
|
export async function ensureLogbookKey(logbookId: string): Promise<ArrayBuffer> {
|
||||||
const localLb = await db.logbooks.get(logbookId)
|
const localLb = await db.logbooks.get(logbookId)
|
||||||
const encryptedTitle = localLb ? localLb.encryptedTitle : ''
|
const encryptedTitle = localLb ? localLb.encryptedTitle : ''
|
||||||
|
const isShared = localLb?.isShared === 1
|
||||||
const masterKey = getActiveMasterKey()
|
const masterKey = getActiveMasterKey()
|
||||||
|
|
||||||
let key = await getLogbookKey(logbookId)
|
let key = await getLogbookKey(logbookId)
|
||||||
@@ -103,6 +104,11 @@ export async function ensureLogbookKey(logbookId: string): Promise<ArrayBuffer>
|
|||||||
// Key works, return it
|
// Key works, return it
|
||||||
return key
|
return key
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (isShared) {
|
||||||
|
throw new Error(
|
||||||
|
'Shared logbook encryption key is missing or invalid. Please go online and refresh your logbooks.'
|
||||||
|
)
|
||||||
|
}
|
||||||
console.warn('Stored logbook key failed to decrypt title. Testing if master key works (legacy migration)...')
|
console.warn('Stored logbook key failed to decrypt title. Testing if master key works (legacy migration)...')
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(encryptedTitle)
|
const parsed = JSON.parse(encryptedTitle)
|
||||||
@@ -145,6 +151,12 @@ export async function ensureLogbookKey(logbookId: string): Promise<ArrayBuffer>
|
|||||||
|
|
||||||
// If no logbook key exists yet
|
// If no logbook key exists yet
|
||||||
if (!key) {
|
if (!key) {
|
||||||
|
if (isShared) {
|
||||||
|
throw new Error(
|
||||||
|
'Shared logbook encryption key not found. Please go online and refresh your logbooks.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (encryptedTitle && masterKey) {
|
if (encryptedTitle && masterKey) {
|
||||||
try {
|
try {
|
||||||
// Check if title is already decryptable using masterKey (meaning it is a legacy logbook)
|
// Check if title is already decryptable using masterKey (meaning it is a legacy logbook)
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
|
import { formatAppDecimal } from '../../utils/numberFormat.js'
|
||||||
import type { NmeaChangeEvent, NmeaDetectionConfig, NmeaTimePoint } from './nmeaTypes.js'
|
import type { NmeaChangeEvent, NmeaDetectionConfig, NmeaTimePoint } from './nmeaTypes.js'
|
||||||
import { DEFAULT_NMEA_DETECTION_CONFIG } from './nmeaTypes.js'
|
import { DEFAULT_NMEA_DETECTION_CONFIG } from './nmeaTypes.js'
|
||||||
import { angularDelta } from './nmeaTimeSeries.js'
|
import { angularDelta } from './nmeaTimeSeries.js'
|
||||||
|
|
||||||
|
function formatNmeaDecimal(value: number): string {
|
||||||
|
return formatAppDecimal(value, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
||||||
|
}
|
||||||
|
|
||||||
function pushUnique(events: NmeaChangeEvent[], event: NmeaChangeEvent, minGapMs: number) {
|
function pushUnique(events: NmeaChangeEvent[], event: NmeaChangeEvent, minGapMs: number) {
|
||||||
const last = events[events.length - 1]
|
const last = events[events.length - 1]
|
||||||
if (last && last.type === event.type && event.timestamp - last.timestamp < minGapMs) return
|
if (last && last.type === event.type && event.timestamp - last.timestamp < minGapMs) return
|
||||||
@@ -64,7 +69,7 @@ export function detectNmeaChanges(
|
|||||||
timestamp: p.timestamp,
|
timestamp: p.timestamp,
|
||||||
confidence: 'medium',
|
confidence: 'medium',
|
||||||
summaryKey: 'logs.nmea_change_wind_speed',
|
summaryKey: 'logs.nmea_change_wind_speed',
|
||||||
summaryParams: { from: lastWindSpeed.toFixed(1), to: p.windSpeedKnots.toFixed(1) },
|
summaryParams: { from: formatNmeaDecimal(lastWindSpeed), to: formatNmeaDecimal(p.windSpeedKnots) },
|
||||||
data: p
|
data: p
|
||||||
}, config.dedupeWindowMs)
|
}, config.dedupeWindowMs)
|
||||||
}
|
}
|
||||||
@@ -79,7 +84,7 @@ export function detectNmeaChanges(
|
|||||||
timestamp: p.timestamp,
|
timestamp: p.timestamp,
|
||||||
confidence: 'medium',
|
confidence: 'medium',
|
||||||
summaryKey: 'logs.nmea_change_pressure',
|
summaryKey: 'logs.nmea_change_pressure',
|
||||||
summaryParams: { from: lastPressure.toFixed(1), to: p.pressureHpa.toFixed(1) },
|
summaryParams: { from: formatNmeaDecimal(lastPressure), to: formatNmeaDecimal(p.pressureHpa) },
|
||||||
data: p
|
data: p
|
||||||
}, config.dedupeWindowMs)
|
}, config.dedupeWindowMs)
|
||||||
}
|
}
|
||||||
@@ -95,7 +100,7 @@ export function detectNmeaChanges(
|
|||||||
timestamp: p.timestamp,
|
timestamp: p.timestamp,
|
||||||
confidence: 'high',
|
confidence: 'high',
|
||||||
summaryKey: 'logs.nmea_change_depth',
|
summaryKey: 'logs.nmea_change_depth',
|
||||||
summaryParams: { from: lastDepth.toFixed(1), to: p.depthM.toFixed(1) },
|
summaryParams: { from: formatNmeaDecimal(lastDepth), to: formatNmeaDecimal(p.depthM) },
|
||||||
data: p
|
data: p
|
||||||
}, config.dedupeWindowMs)
|
}, config.dedupeWindowMs)
|
||||||
}
|
}
|
||||||
@@ -156,7 +161,7 @@ export function detectNmeaChanges(
|
|||||||
timestamp: p.timestamp,
|
timestamp: p.timestamp,
|
||||||
confidence: 'medium',
|
confidence: 'medium',
|
||||||
summaryKey: 'logs.nmea_change_water_temp',
|
summaryKey: 'logs.nmea_change_water_temp',
|
||||||
summaryParams: { from: lastWaterTemp.toFixed(1), to: p.waterTempC.toFixed(1) },
|
summaryParams: { from: formatNmeaDecimal(lastWaterTemp), to: formatNmeaDecimal(p.waterTempC) },
|
||||||
data: p
|
data: p
|
||||||
}, config.dedupeWindowMs)
|
}, config.dedupeWindowMs)
|
||||||
}
|
}
|
||||||
@@ -200,7 +205,7 @@ export function detectNmeaChanges(
|
|||||||
timestamp: p.timestamp,
|
timestamp: p.timestamp,
|
||||||
confidence: 'low',
|
confidence: 'low',
|
||||||
summaryKey: 'logs.nmea_change_speed',
|
summaryKey: 'logs.nmea_change_speed',
|
||||||
summaryParams: { from: lastSog.toFixed(1), to: sog.toFixed(1) },
|
summaryParams: { from: formatNmeaDecimal(lastSog), to: formatNmeaDecimal(sog) },
|
||||||
data: p
|
data: p
|
||||||
}, config.dedupeWindowMs)
|
}, config.dedupeWindowMs)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { TFunction } from 'i18next'
|
|||||||
import type { LogEventPayload } from '../../utils/logEntryPayload.js'
|
import type { LogEventPayload } from '../../utils/logEntryPayload.js'
|
||||||
import { normalizeLogEvent } from '../../utils/logEntryPayload.js'
|
import { normalizeLogEvent } from '../../utils/logEntryPayload.js'
|
||||||
import { formatCourseAngle } from '../../utils/courseAngle.js'
|
import { formatCourseAngle } from '../../utils/courseAngle.js'
|
||||||
|
import { formatAppDecimal, formatCanonicalCoordinate } from '../../utils/numberFormat.js'
|
||||||
import { degreesToCardinal } from '../../utils/courseAngle.js'
|
import { degreesToCardinal } from '../../utils/courseAngle.js'
|
||||||
import type {
|
import type {
|
||||||
NmeaChangeEvent,
|
NmeaChangeEvent,
|
||||||
@@ -33,9 +34,12 @@ function pointToLogEvent(
|
|||||||
windDirection: windDir,
|
windDirection: windDir,
|
||||||
windStrength: point.windSpeedKnots != null ? String(point.windSpeedKnots) : '',
|
windStrength: point.windSpeedKnots != null ? String(point.windSpeedKnots) : '',
|
||||||
windPressure: point.pressureHpa != null ? String(Math.round(point.pressureHpa)) : '',
|
windPressure: point.pressureHpa != null ? String(Math.round(point.pressureHpa)) : '',
|
||||||
gpsLat: point.lat != null ? point.lat.toFixed(6) : '',
|
gpsLat: point.lat != null ? formatCanonicalCoordinate(point.lat) : '',
|
||||||
gpsLng: point.lng != null ? point.lng.toFixed(6) : '',
|
gpsLng: point.lng != null ? formatCanonicalCoordinate(point.lng) : '',
|
||||||
logReading: point.logDistanceNm != null ? point.logDistanceNm.toFixed(2) : '',
|
logReading:
|
||||||
|
point.logDistanceNm != null
|
||||||
|
? formatAppDecimal(point.logDistanceNm, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||||
|
: '',
|
||||||
sailsOrMotor,
|
sailsOrMotor,
|
||||||
remarks
|
remarks
|
||||||
})
|
})
|
||||||
@@ -51,7 +55,11 @@ function buildRemarks(change: NmeaChangeEvent, t: TFunction): string {
|
|||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
parts.push(t(change.summaryKey, change.summaryParams ?? {}))
|
parts.push(t(change.summaryKey, change.summaryParams ?? {}))
|
||||||
if (change.data?.depthM != null) {
|
if (change.data?.depthM != null) {
|
||||||
parts.push(t('logs.nmea_remark_depth', { depth: change.data.depthM.toFixed(1) }))
|
parts.push(
|
||||||
|
t('logs.nmea_remark_depth', {
|
||||||
|
depth: formatAppDecimal(change.data.depthM, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
||||||
|
})
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (change.confidence === 'low') {
|
if (change.confidence === 'low') {
|
||||||
parts.push(t('logs.nmea_remark_uncertain'))
|
parts.push(t('logs.nmea_remark_uncertain'))
|
||||||
|
|||||||
@@ -13,12 +13,13 @@ function formatPasskeySignDate(signedAt: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function generateLogbookPagePdf(logbookId: string, entryId: string, preloadedData?: { yacht: any; entry: any }): Promise<jsPDF> {
|
export async function generateLogbookPagePdf(logbookId: string, entryId: string, preloadedData?: { yacht: any; entry: any }): Promise<jsPDF> {
|
||||||
let yachtName = '', homePort = '', registration = '', callsign = '', atis = '', mmsi = '';
|
let yachtName = '', owner = '', homePort = '', registration = '', callsign = '', atis = '', mmsi = '';
|
||||||
let entry: any = null;
|
let entry: any = null;
|
||||||
|
|
||||||
if (preloadedData) {
|
if (preloadedData) {
|
||||||
const yacht = preloadedData.yacht || {};
|
const yacht = preloadedData.yacht || {};
|
||||||
yachtName = yacht.name || '';
|
yachtName = yacht.name || '';
|
||||||
|
owner = yacht.owner || '';
|
||||||
homePort = yacht.port || '';
|
homePort = yacht.port || '';
|
||||||
registration = yacht.registrationNumber || yacht.registration || '';
|
registration = yacht.registrationNumber || yacht.registration || '';
|
||||||
callsign = yacht.callSign || '';
|
callsign = yacht.callSign || '';
|
||||||
@@ -35,6 +36,7 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
|
|||||||
const yacht = await resolveVesselForLogbook(logbookId)
|
const yacht = await resolveVesselForLogbook(logbookId)
|
||||||
if (yacht) {
|
if (yacht) {
|
||||||
yachtName = yacht.name || ''
|
yachtName = yacht.name || ''
|
||||||
|
owner = yacht.owner || ''
|
||||||
homePort = yacht.homePort || ''
|
homePort = yacht.homePort || ''
|
||||||
registration = yacht.registrationNumber || ''
|
registration = yacht.registrationNumber || ''
|
||||||
callsign = yacht.callSign || ''
|
callsign = yacht.callSign || ''
|
||||||
@@ -74,24 +76,56 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
|
|||||||
doc.setFontSize(8.5);
|
doc.setFontSize(8.5);
|
||||||
doc.setFont('Helvetica', 'normal');
|
doc.setFont('Helvetica', 'normal');
|
||||||
doc.text(`Yachtname: ${yachtName || '—'}`, 10, 21);
|
doc.text(`Yachtname: ${yachtName || '—'}`, 10, 21);
|
||||||
doc.text(`Heimathafen: ${homePort || '—'}`, 60, 21);
|
doc.text(`Eigner: ${owner || '—'}`, 55, 21);
|
||||||
doc.text(`Kennzeichen: ${registration || '—'}`, 110, 21);
|
doc.text(`Heimathafen: ${homePort || '—'}`, 100, 21);
|
||||||
doc.text(`Rufzeichen: ${callsign || '—'}`, 160, 21);
|
doc.text(`Kennzeichen: ${registration || '—'}`, 145, 21);
|
||||||
doc.text(`ATIS: ${atis || '—'}`, 210, 21);
|
doc.text(`Rufzeichen: ${callsign || '—'}`, 190, 21);
|
||||||
doc.text(`MMSI: ${mmsi || '—'}`, 250, 21);
|
doc.text(`ATIS: ${atis || '—'}`, 230, 21);
|
||||||
|
doc.text(`MMSI: ${mmsi || '—'}`, 260, 21);
|
||||||
|
|
||||||
doc.text(`Datum: ${entry.date || '—'}`, 10, 23);
|
doc.text(`Datum: ${entry.date || '—'}`, 10, 24);
|
||||||
doc.text(`Reisetag: ${entry.dayOfTravel || '—'}`, 60, 23);
|
doc.text(`Reisetag: ${entry.dayOfTravel || '—'}`, 60, 24);
|
||||||
doc.text(`Reise von (Departure): ${entry.departure || '—'}`, 110, 23);
|
doc.text(`Reise von (Departure): ${entry.departure || '—'}`, 110, 24);
|
||||||
doc.text(`nach (Destination): ${entry.destination || '—'}`, 200, 23);
|
doc.text(`nach (Destination): ${entry.destination || '—'}`, 200, 24);
|
||||||
|
|
||||||
|
// Format Crew names with initials
|
||||||
|
const crewSnapshots = (entry.crewSnapshotsById as Record<string, any>) || {}
|
||||||
|
const crewList: string[] = []
|
||||||
|
|
||||||
|
if (entry.selectedSkipperId && crewSnapshots[entry.selectedSkipperId]) {
|
||||||
|
const name = crewSnapshots[entry.selectedSkipperId].name || 'Skipper'
|
||||||
|
const initial = name.trim().split(/\s+/)[0]?.charAt(0).toUpperCase() || 'S'
|
||||||
|
crewList.push(`${name} [${initial}] (Skipper)`)
|
||||||
|
} else if (crewSnapshots['skipper']) {
|
||||||
|
const name = crewSnapshots['skipper'].name || 'Skipper'
|
||||||
|
crewList.push(`${name} [S] (Skipper)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(entry.selectedCrewIds)) {
|
||||||
|
for (const crewId of entry.selectedCrewIds) {
|
||||||
|
const snap = crewSnapshots[crewId]
|
||||||
|
if (snap) {
|
||||||
|
const name = snap.name || ''
|
||||||
|
const initial = name.trim().split(/\s+/)[0]?.charAt(0).toUpperCase() || '?'
|
||||||
|
crewList.push(`${name} [${initial}]`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const crewText = crewList.length > 0 ? `Besatzung (Crew): ${crewList.join(', ')}` : ''
|
||||||
|
|
||||||
|
doc.setFont('Helvetica', 'normal');
|
||||||
if (entry.trackDistanceNm) {
|
if (entry.trackDistanceNm) {
|
||||||
doc.setFont('Helvetica', 'normal');
|
|
||||||
doc.text(
|
doc.text(
|
||||||
`GPS-Track: ${entry.trackDistanceNm} sm · max. ${entry.trackSpeedMaxKn ?? '—'} kn · Ø ${entry.trackSpeedAvgKn ?? '—'} kn`,
|
`GPS-Track: ${entry.trackDistanceNm} sm · max. ${entry.trackSpeedMaxKn ?? '—'} kn · Ø ${entry.trackSpeedAvgKn ?? '—'} kn`,
|
||||||
10,
|
10,
|
||||||
27
|
27
|
||||||
);
|
);
|
||||||
|
if (crewText) {
|
||||||
|
doc.text(crewText, 140, 27);
|
||||||
|
}
|
||||||
|
} else if (crewText) {
|
||||||
|
doc.text(crewText, 10, 27);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Divider line
|
// Divider line
|
||||||
@@ -175,8 +209,28 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
|
|||||||
doc.text(gps, writeX + 1, y + 4.2);
|
doc.text(gps, writeX + 1, y + 4.2);
|
||||||
writeX += colWidths[11];
|
writeX += colWidths[11];
|
||||||
|
|
||||||
|
const crewSnapshots = (entry.crewSnapshotsById as Record<string, any>) || {};
|
||||||
|
let initial = '';
|
||||||
|
if (ev.creatorId) {
|
||||||
|
const snap = crewSnapshots[ev.creatorId];
|
||||||
|
let name = '';
|
||||||
|
if (snap) {
|
||||||
|
name = snap.name || '';
|
||||||
|
} else if (ev.creatorId === 'skipper') {
|
||||||
|
name = 'Skipper';
|
||||||
|
} else {
|
||||||
|
name = ev.creatorId;
|
||||||
|
}
|
||||||
|
if (name) {
|
||||||
|
initial = name.trim().split(/\s+/)[0]?.charAt(0).toUpperCase() || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Clip remarks to fit within the 94mm bounds
|
// Clip remarks to fit within the 94mm bounds
|
||||||
const remarks = ev.remarks || '';
|
let remarks = ev.remarks || '';
|
||||||
|
if (initial) {
|
||||||
|
remarks = `[${initial}] ${remarks}`;
|
||||||
|
}
|
||||||
const maxChars = 65;
|
const maxChars = 65;
|
||||||
const clippedRemarks = remarks.length > maxChars ? remarks.substring(0, maxChars) + '...' : remarks;
|
const clippedRemarks = remarks.length > maxChars ? remarks.substring(0, maxChars) + '...' : remarks;
|
||||||
doc.text(clippedRemarks, writeX + 1, y + 4.2);
|
doc.text(clippedRemarks, writeX + 1, y + 4.2);
|
||||||
|
|||||||
@@ -55,9 +55,6 @@ export async function saveEntryPhoto(options: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: analyticsContext })
|
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))
|
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||||
return photoId
|
return photoId
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,29 +2,6 @@ import { apiFetch, apiJson } from './api.js'
|
|||||||
|
|
||||||
const API_BASE = '/api/push'
|
const API_BASE = '/api/push'
|
||||||
|
|
||||||
export async function logToBackend(message: string, error?: any): Promise<void> {
|
|
||||||
try {
|
|
||||||
await fetch(`${API_BASE}/debug-log`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
message,
|
|
||||||
error: error ? {
|
|
||||||
name: error.name,
|
|
||||||
message: error.message,
|
|
||||||
stack: error.stack,
|
|
||||||
...error
|
|
||||||
} : undefined,
|
|
||||||
userAgent: navigator.userAgent,
|
|
||||||
href: window.location.href,
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('Failed to send debug log:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
|
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
|
||||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
|
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||||
@@ -212,35 +189,23 @@ async function saveSubscriptionToServer(subscription: PushSubscription): Promise
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function subscribeToPush(): Promise<void> {
|
export async function subscribeToPush(): Promise<void> {
|
||||||
logToBackend('subscribeToPush called')
|
|
||||||
if (!isPushSupported()) {
|
if (!isPushSupported()) {
|
||||||
logToBackend('subscribeToPush: push not supported')
|
|
||||||
throw new Error('Push notifications are not supported on this device')
|
throw new Error('Push notifications are not supported on this device')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pre-resolve registration using getRegistrationCompat to prevent ready state hangs
|
// Pre-resolve registration using getRegistrationCompat to prevent ready state hangs
|
||||||
let registration = cachedRegistration
|
let registration = cachedRegistration
|
||||||
if (!registration) {
|
if (!registration) {
|
||||||
try {
|
registration = await getRegistrationCompat()
|
||||||
logToBackend('subscribeToPush: getting registration...')
|
cachedRegistration = registration
|
||||||
registration = await getRegistrationCompat()
|
|
||||||
cachedRegistration = registration
|
|
||||||
logToBackend('subscribeToPush: got registration successfully')
|
|
||||||
} catch (err) {
|
|
||||||
logToBackend('subscribeToPush: failed to get registration', err)
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const publicKey = cachedVapidKey || await fetchVapidPublicKey()
|
const publicKey = cachedVapidKey || await fetchVapidPublicKey()
|
||||||
if (!publicKey) {
|
if (!publicKey) {
|
||||||
logToBackend('subscribeToPush: no public key available')
|
|
||||||
throw new Error('Push notifications are not configured on this server')
|
throw new Error('Push notifications are not configured on this server')
|
||||||
}
|
}
|
||||||
|
|
||||||
logToBackend('subscribeToPush: requesting permission...')
|
|
||||||
const permission = await requestNotificationPermission()
|
const permission = await requestNotificationPermission()
|
||||||
logToBackend(`subscribeToPush: permission result: ${permission}`)
|
|
||||||
if (permission !== 'granted') {
|
if (permission !== 'granted') {
|
||||||
throw new Error('Notification permission denied')
|
throw new Error('Notification permission denied')
|
||||||
}
|
}
|
||||||
@@ -249,7 +214,6 @@ export async function subscribeToPush(): Promise<void> {
|
|||||||
const applicationServerKey = new Uint8Array(keyBytes)
|
const applicationServerKey = new Uint8Array(keyBytes)
|
||||||
|
|
||||||
// Always call subscribe with timeout to prevent silent hangs on push network errors
|
// Always call subscribe with timeout to prevent silent hangs on push network errors
|
||||||
logToBackend('subscribeToPush: subscribing via pushManager...')
|
|
||||||
const subscribePromise = registration.pushManager.subscribe({
|
const subscribePromise = registration.pushManager.subscribe({
|
||||||
userVisibleOnly: true,
|
userVisibleOnly: true,
|
||||||
applicationServerKey
|
applicationServerKey
|
||||||
@@ -257,15 +221,9 @@ export async function subscribeToPush(): Promise<void> {
|
|||||||
const subscribeTimeout = new Promise<never>((_, reject) =>
|
const subscribeTimeout = new Promise<never>((_, reject) =>
|
||||||
setTimeout(() => reject(new Error('Timeout establishing subscription with push service (FCM/APNs)')), 12000)
|
setTimeout(() => reject(new Error('Timeout establishing subscription with push service (FCM/APNs)')), 12000)
|
||||||
)
|
)
|
||||||
try {
|
const subscription = await Promise.race([subscribePromise, subscribeTimeout])
|
||||||
const subscription = await Promise.race([subscribePromise, subscribeTimeout])
|
|
||||||
logToBackend('subscribeToPush: subscribed successfully, saving to server...')
|
await saveSubscriptionToServer(subscription)
|
||||||
await saveSubscriptionToServer(subscription)
|
|
||||||
logToBackend('subscribeToPush: saved to server successfully')
|
|
||||||
} catch (err) {
|
|
||||||
logToBackend('subscribeToPush: subscription or save failed', err)
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function unsubscribeFromPush(): Promise<void> {
|
export async function unsubscribeFromPush(): Promise<void> {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
normalizeLogEvent,
|
normalizeLogEvent,
|
||||||
sortLogEventsByTime,
|
sortLogEventsByTime,
|
||||||
currentLocalTimeHHMM,
|
currentLocalTimeHHMM,
|
||||||
|
localDateString,
|
||||||
type LogEventPayload
|
type LogEventPayload
|
||||||
} from '../utils/logEntryPayload.js'
|
} from '../utils/logEntryPayload.js'
|
||||||
import {
|
import {
|
||||||
@@ -96,6 +97,14 @@ function buildEncryptedPayload(
|
|||||||
consumption: fuel.consumption ?? 0
|
consumption: fuel.consumption ?? 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const entryCrew = data.selectedSkipperId
|
||||||
|
? {
|
||||||
|
selectedSkipperId: String(data.selectedSkipperId),
|
||||||
|
selectedCrewIds: Array.isArray(data.selectedCrewIds) ? data.selectedCrewIds.map(String) : [],
|
||||||
|
crewSnapshotsById: (data.crewSnapshotsById as Record<string, any>) || {}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
|
||||||
const payload = buildLogEntryPayload({
|
const payload = buildLogEntryPayload({
|
||||||
date: String(data.date || ''),
|
date: String(data.date || ''),
|
||||||
dayOfTravel: String(data.dayOfTravel || ''),
|
dayOfTravel: String(data.dayOfTravel || ''),
|
||||||
@@ -120,15 +129,27 @@ function buildEncryptedPayload(
|
|||||||
motorHoursRaw != null && motorHoursRaw !== ''
|
motorHoursRaw != null && motorHoursRaw !== ''
|
||||||
? parseFloat(String(motorHoursRaw))
|
? parseFloat(String(motorHoursRaw))
|
||||||
: undefined,
|
: undefined,
|
||||||
events: options.events
|
events: options.events,
|
||||||
|
entryCrew
|
||||||
})
|
})
|
||||||
|
|
||||||
const clear = options.clearSignatures
|
const clear = options.clearSignatures
|
||||||
return {
|
const entryData: Record<string, unknown> = {
|
||||||
...payload,
|
...payload,
|
||||||
signSkipper: clear ? '' : (data.signSkipper ?? ''),
|
signSkipper: clear ? '' : (data.signSkipper ?? ''),
|
||||||
signCrew: clear ? '' : (data.signCrew ?? '')
|
signCrew: clear ? '' : (data.signCrew ?? '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const summary = typeof data.aiSummary === 'string' ? data.aiSummary.trim() : ''
|
||||||
|
if (summary) {
|
||||||
|
entryData.aiSummary = summary
|
||||||
|
entryData.aiSummaryGeneratedAt =
|
||||||
|
typeof data.aiSummaryGeneratedAt === 'string' && data.aiSummaryGeneratedAt
|
||||||
|
? data.aiSummaryGeneratedAt
|
||||||
|
: new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
return entryData
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadEntry(logbookId: string, entryId: string): Promise<LoadedEntry | null> {
|
export async function loadEntry(logbookId: string, entryId: string): Promise<LoadedEntry | null> {
|
||||||
@@ -140,18 +161,86 @@ export async function loadEntry(logbookId: string, entryId: string): Promise<Loa
|
|||||||
return { payloadId: record.payloadId, updatedAt: record.updatedAt, data }
|
return { payloadId: record.payloadId, updatedAt: record.updatedAt, data }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scoreTodayEntry(data: Record<string, unknown>): number {
|
||||||
|
const events = (data.events as unknown[] | undefined)?.length ?? 0
|
||||||
|
const signed = (data.signSkipper || data.signCrew) ? 1 : 0
|
||||||
|
const destination = String(data.destination || '').trim() ? 1 : 0
|
||||||
|
return events * 10 + signed + destination
|
||||||
|
}
|
||||||
|
|
||||||
export async function findTodayEntryId(logbookId: string): Promise<string | null> {
|
export async function findTodayEntryId(logbookId: string): Promise<string | null> {
|
||||||
const todayStr = new Date().toISOString().substring(0, 10)
|
const todayStr = localDateString()
|
||||||
const masterKey = await getMasterKey(logbookId)
|
const masterKey = await getMasterKey(logbookId)
|
||||||
const local = sortEntriesNewestFirst(await db.entries.where({ logbookId }).toArray())
|
const local = sortEntriesNewestFirst(await db.entries.where({ logbookId }).toArray())
|
||||||
|
|
||||||
|
let bestId: string | null = null
|
||||||
|
let bestScore = -1
|
||||||
|
let bestUpdatedAt = ''
|
||||||
|
|
||||||
for (const entry of local) {
|
for (const entry of local) {
|
||||||
const decrypted = await tryDecryptEntryPayload(entry, masterKey)
|
const decrypted = await tryDecryptEntryPayload(entry, masterKey)
|
||||||
if (decrypted && String(decrypted.date) === todayStr) {
|
if (!decrypted || String(decrypted.date) !== todayStr) continue
|
||||||
return entry.payloadId
|
|
||||||
|
const score = scoreTodayEntry(decrypted)
|
||||||
|
if (
|
||||||
|
score > bestScore
|
||||||
|
|| (score === bestScore && entry.updatedAt > bestUpdatedAt)
|
||||||
|
) {
|
||||||
|
bestId = entry.payloadId
|
||||||
|
bestScore = score
|
||||||
|
bestUpdatedAt = entry.updatedAt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null
|
|
||||||
|
return bestId
|
||||||
|
}
|
||||||
|
|
||||||
|
async function entryHasAttachments(logbookId: string, entryId: string): Promise<boolean> {
|
||||||
|
const [photos, voices, track] = await Promise.all([
|
||||||
|
db.photos.where({ logbookId, entryId }).count(),
|
||||||
|
db.voiceMemos.where({ logbookId, entryId }).count(),
|
||||||
|
db.gpsTracks.get(entryId)
|
||||||
|
])
|
||||||
|
return photos > 0 || voices > 0 || track != null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isEmptyTodayEntry(
|
||||||
|
logbookId: string,
|
||||||
|
entryId: string,
|
||||||
|
data: Record<string, unknown>
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (((data.events as unknown[] | undefined)?.length ?? 0) > 0) return false
|
||||||
|
if (data.signSkipper || data.signCrew) return false
|
||||||
|
if (String(data.destination || '').trim()) return false
|
||||||
|
return !(await entryHasAttachments(logbookId, entryId))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove duplicate empty travel days for today (e.g. after parallel Live-log init). */
|
||||||
|
export async function pruneEmptyTodayDuplicates(
|
||||||
|
logbookId: string,
|
||||||
|
keepEntryId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const todayStr = localDateString()
|
||||||
|
const masterKey = await getMasterKey(logbookId)
|
||||||
|
const local = await db.entries.where({ logbookId }).toArray()
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
|
for (const entry of local) {
|
||||||
|
if (entry.payloadId === keepEntryId) continue
|
||||||
|
const decrypted = await tryDecryptEntryPayload(entry, masterKey)
|
||||||
|
if (!decrypted || String(decrypted.date) !== todayStr) continue
|
||||||
|
if (!(await isEmptyTodayEntry(logbookId, entry.payloadId, decrypted))) continue
|
||||||
|
|
||||||
|
await db.entries.delete(entry.payloadId)
|
||||||
|
await db.syncQueue.put({
|
||||||
|
action: 'delete',
|
||||||
|
type: 'entry',
|
||||||
|
payloadId: entry.payloadId,
|
||||||
|
logbookId,
|
||||||
|
data: '',
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createTodayEntry(logbookId: string): Promise<string> {
|
export async function createTodayEntry(logbookId: string): Promise<string> {
|
||||||
@@ -174,7 +263,7 @@ export async function createTodayEntry(logbookId: string): Promise<string> {
|
|||||||
|
|
||||||
const localId = window.crypto.randomUUID()
|
const localId = window.crypto.randomUUID()
|
||||||
const nowStr = new Date().toISOString()
|
const nowStr = new Date().toISOString()
|
||||||
const todayStr = nowStr.substring(0, 10)
|
const todayStr = localDateString()
|
||||||
|
|
||||||
const initialPayload = {
|
const initialPayload = {
|
||||||
date: todayStr,
|
date: todayStr,
|
||||||
@@ -216,20 +305,36 @@ export async function createTodayEntry(logbookId: string): Promise<string> {
|
|||||||
return localId
|
return localId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const findOrCreateTodayEntryInflight = new Map<string, Promise<string>>()
|
||||||
|
|
||||||
|
async function findOrCreateTodayEntryOnce(logbookId: string): Promise<string> {
|
||||||
|
await ensureLogbookKey(logbookId)
|
||||||
|
|
||||||
|
let entryId = await findTodayEntryId(logbookId)
|
||||||
|
if (!entryId) {
|
||||||
|
entryId = await createTodayEntry(logbookId)
|
||||||
|
}
|
||||||
|
|
||||||
|
await pruneEmptyTodayDuplicates(logbookId, entryId)
|
||||||
|
return entryId
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One travel day per local calendar date; concurrent callers share one in-flight create. */
|
||||||
export async function findOrCreateTodayEntry(logbookId: string): Promise<string> {
|
export async function findOrCreateTodayEntry(logbookId: string): Promise<string> {
|
||||||
const id = logbookId.trim()
|
const id = logbookId.trim()
|
||||||
if (!id) throw new Error('Logbook id required')
|
if (!id) throw new Error('Logbook id required')
|
||||||
|
|
||||||
await ensureLogbookKey(id)
|
let inflight = findOrCreateTodayEntryInflight.get(id)
|
||||||
|
if (!inflight) {
|
||||||
const entryCount = await db.entries.where({ logbookId: id }).count()
|
inflight = findOrCreateTodayEntryOnce(id)
|
||||||
if (entryCount === 0) {
|
findOrCreateTodayEntryInflight.set(id, inflight)
|
||||||
return createTodayEntry(id)
|
void inflight.finally(() => {
|
||||||
|
if (findOrCreateTodayEntryInflight.get(id) === inflight) {
|
||||||
|
findOrCreateTodayEntryInflight.delete(id)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
return inflight
|
||||||
const existing = await findTodayEntryId(id)
|
|
||||||
if (existing) return existing
|
|
||||||
return createTodayEntry(id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppendQuickEventResult {
|
export interface AppendQuickEventResult {
|
||||||
|
|||||||
@@ -258,14 +258,4 @@ export function getTrackColor(index: number): string {
|
|||||||
return TRACK_COLORS[index % TRACK_COLORS.length]
|
return TRACK_COLORS[index % TRACK_COLORS.length]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatNm(value: number): string {
|
export { formatHours, formatLiters, formatNm } from '../utils/numberFormat.js'
|
||||||
return value.toFixed(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatLiters(value: number): string {
|
|
||||||
return Number.isInteger(value) ? String(value) : value.toFixed(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatHours(value: number): string {
|
|
||||||
return Number.isInteger(value) ? String(value) : value.toFixed(1)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ async function entityExistsLocally(item: SyncQueueItem): Promise<boolean> {
|
|||||||
return !!(await db.entries.get(item.payloadId))
|
return !!(await db.entries.get(item.payloadId))
|
||||||
case 'photo':
|
case 'photo':
|
||||||
return !!(await db.photos.get(item.payloadId))
|
return !!(await db.photos.get(item.payloadId))
|
||||||
|
case 'voiceMemo':
|
||||||
|
return !!(await db.voiceMemos.get(item.payloadId))
|
||||||
case 'gpsTrack':
|
case 'gpsTrack':
|
||||||
return !!(await db.gpsTracks.get(item.payloadId))
|
return !!(await db.gpsTracks.get(item.payloadId))
|
||||||
case 'logbookCrew':
|
case 'logbookCrew':
|
||||||
@@ -230,6 +232,7 @@ type PulledServerPayload = {
|
|||||||
crews?: Array<{ payloadId: string; updatedAt: string }>
|
crews?: Array<{ payloadId: string; updatedAt: string }>
|
||||||
entries?: Array<{ payloadId: string; updatedAt: string }>
|
entries?: Array<{ payloadId: string; updatedAt: string }>
|
||||||
photos?: Array<{ payloadId: string; updatedAt: string }>
|
photos?: Array<{ payloadId: string; updatedAt: string }>
|
||||||
|
voiceMemos?: Array<{ payloadId: string; updatedAt: string }>
|
||||||
gpsTracks?: Array<{ entryId: string; updatedAt: string }>
|
gpsTracks?: Array<{ entryId: string; updatedAt: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,6 +256,7 @@ async function pruneAcknowledgedQueueItems(
|
|||||||
for (const c of server.crews ?? []) serverTimes.set('crew:' + c.payloadId, c.updatedAt)
|
for (const c of server.crews ?? []) serverTimes.set('crew:' + c.payloadId, c.updatedAt)
|
||||||
for (const e of server.entries ?? []) serverTimes.set('entry:' + e.payloadId, e.updatedAt)
|
for (const e of server.entries ?? []) serverTimes.set('entry:' + e.payloadId, e.updatedAt)
|
||||||
for (const p of server.photos ?? []) serverTimes.set('photo:' + p.payloadId, p.updatedAt)
|
for (const p of server.photos ?? []) serverTimes.set('photo:' + p.payloadId, p.updatedAt)
|
||||||
|
for (const v of server.voiceMemos ?? []) serverTimes.set('voiceMemo:' + v.payloadId, v.updatedAt)
|
||||||
for (const gt of server.gpsTracks ?? []) serverTimes.set('gpsTrack:' + gt.entryId, gt.updatedAt)
|
for (const gt of server.gpsTracks ?? []) serverTimes.set('gpsTrack:' + gt.entryId, gt.updatedAt)
|
||||||
|
|
||||||
const localLogbook = await db.logbooks.get(logbookId)
|
const localLogbook = await db.logbooks.get(logbookId)
|
||||||
@@ -299,7 +303,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const { yacht, deviation, crews, logbookCrewSelection, logbookVesselSelection, entries, photos, gpsTracks } =
|
const { yacht, deviation, crews, logbookCrewSelection, logbookVesselSelection, entries, photos, voiceMemos, gpsTracks } =
|
||||||
await response.json()
|
await response.json()
|
||||||
|
|
||||||
// Large pull payloads block on JSON.parse — yield before applying to IndexedDB.
|
// Large pull payloads block on JSON.parse — yield before applying to IndexedDB.
|
||||||
@@ -313,6 +317,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
|||||||
crews,
|
crews,
|
||||||
entries,
|
entries,
|
||||||
photos,
|
photos,
|
||||||
|
voiceMemos,
|
||||||
gpsTracks
|
gpsTracks
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -471,6 +476,38 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 5b. Sync Voice Memos
|
||||||
|
const serverVoiceMap = new Map<string, any>()
|
||||||
|
if (voiceMemos && Array.isArray(voiceMemos)) {
|
||||||
|
await forEachInBatches(voiceMemos, 20, async (v) => {
|
||||||
|
serverVoiceMap.set(v.payloadId, v)
|
||||||
|
const local = await db.voiceMemos.get(v.payloadId)
|
||||||
|
if (!local || isNewer(v.updatedAt, local.updatedAt)) {
|
||||||
|
await db.voiceMemos.put({
|
||||||
|
payloadId: v.payloadId,
|
||||||
|
entryId: v.entryId,
|
||||||
|
logbookId,
|
||||||
|
encryptedData: v.encryptedData,
|
||||||
|
iv: v.iv,
|
||||||
|
tag: v.tag,
|
||||||
|
updatedAt: v.updatedAt
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const localVoiceMemos = await db.voiceMemos.where({ logbookId }).toArray()
|
||||||
|
for (const lv of localVoiceMemos) {
|
||||||
|
if (!serverVoiceMap.has(lv.payloadId)) {
|
||||||
|
const pendingCreate = await db.syncQueue
|
||||||
|
.where({ payloadId: lv.payloadId, action: 'create' })
|
||||||
|
.first()
|
||||||
|
if (!pendingCreate) {
|
||||||
|
await db.voiceMemos.delete(lv.payloadId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 6. Sync GPS Tracks
|
// 6. Sync GPS Tracks
|
||||||
const serverGpsTrackMap = new Map<string, any>()
|
const serverGpsTrackMap = new Map<string, any>()
|
||||||
if (gpsTracks && Array.isArray(gpsTracks)) {
|
if (gpsTracks && Array.isArray(gpsTracks)) {
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import {
|
|||||||
getThemePreference,
|
getThemePreference,
|
||||||
setColorSchemePreference,
|
setColorSchemePreference,
|
||||||
setOwmApiKey,
|
setOwmApiKey,
|
||||||
setThemePreference
|
setThemePreference,
|
||||||
|
getAiAuthorized,
|
||||||
|
setAiAuthorized
|
||||||
} from './userPreferences.js'
|
} from './userPreferences.js'
|
||||||
|
|
||||||
const USER_ID = 'test-user-123'
|
const USER_ID = 'test-user-123'
|
||||||
@@ -58,4 +60,13 @@ describe('userPreferences', () => {
|
|||||||
expect(getThemePreference(USER_ID)).toBe('ocean')
|
expect(getThemePreference(USER_ID)).toBe('ocean')
|
||||||
expect(getColorSchemePreference(USER_ID)).toBe('light')
|
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))
|
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,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 () => {
|
it('does not track when the API request fails', async () => {
|
||||||
apiFetch.mockResolvedValue({
|
apiFetch.mockResolvedValue({
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -58,4 +69,51 @@ describe('fetchOpenWeatherCurrent', () => {
|
|||||||
|
|
||||||
expect(trackPlausibleEvent).not.toHaveBeenCalled()
|
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,9 +7,12 @@ import {
|
|||||||
} from './analytics.js'
|
} from './analytics.js'
|
||||||
|
|
||||||
export class WeatherApiError extends Error {
|
export class WeatherApiError extends Error {
|
||||||
code: 'NO_KEY' | 'REQUEST_FAILED'
|
code: 'NO_KEY' | 'OFFLINE' | 'REQUEST_FAILED' | 'UNAUTHORIZED' | 'NOT_FOUND' | 'BAD_REQUEST'
|
||||||
|
|
||||||
constructor(message: string, code: 'NO_KEY' | 'REQUEST_FAILED' = 'REQUEST_FAILED') {
|
constructor(
|
||||||
|
message: string,
|
||||||
|
code: 'NO_KEY' | 'OFFLINE' | 'REQUEST_FAILED' | 'UNAUTHORIZED' | 'NOT_FOUND' | 'BAD_REQUEST' = 'REQUEST_FAILED'
|
||||||
|
) {
|
||||||
super(message)
|
super(message)
|
||||||
this.name = 'WeatherApiError'
|
this.name = 'WeatherApiError'
|
||||||
this.code = code
|
this.code = code
|
||||||
@@ -26,6 +29,10 @@ export async function fetchOpenWeatherCurrent(
|
|||||||
},
|
},
|
||||||
options?: { analyticsSource: OwmAnalyticsSource }
|
options?: { analyticsSource: OwmAnalyticsSource }
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
|
if (!navigator.onLine) {
|
||||||
|
throw new WeatherApiError('Offline', 'OFFLINE')
|
||||||
|
}
|
||||||
|
|
||||||
const searchParams = new URLSearchParams()
|
const searchParams = new URLSearchParams()
|
||||||
|
|
||||||
if (params.lat && params.lon) {
|
if (params.lat && params.lon) {
|
||||||
@@ -34,7 +41,7 @@ export async function fetchOpenWeatherCurrent(
|
|||||||
} else if (params.q?.trim()) {
|
} else if (params.q?.trim()) {
|
||||||
searchParams.set('q', params.q.trim())
|
searchParams.set('q', params.q.trim())
|
||||||
} else {
|
} else {
|
||||||
throw new WeatherApiError('lat/lon or location query required')
|
throw new WeatherApiError('lat/lon or location query required', 'BAD_REQUEST')
|
||||||
}
|
}
|
||||||
|
|
||||||
const userKey = getOwmApiKeyForActiveUser().trim()
|
const userKey = getOwmApiKeyForActiveUser().trim()
|
||||||
@@ -61,6 +68,15 @@ export async function fetchOpenWeatherCurrent(
|
|||||||
if (res.status === 503) {
|
if (res.status === 503) {
|
||||||
throw new WeatherApiError('No OpenWeatherMap API key configured', 'NO_KEY')
|
throw new WeatherApiError('No OpenWeatherMap API key configured', 'NO_KEY')
|
||||||
}
|
}
|
||||||
|
if (res.status === 401) {
|
||||||
|
throw new WeatherApiError('Invalid OpenWeatherMap API key', 'UNAUTHORIZED')
|
||||||
|
}
|
||||||
|
if (res.status === 404) {
|
||||||
|
throw new WeatherApiError('Location or coordinates not found', 'NOT_FOUND')
|
||||||
|
}
|
||||||
|
if (res.status === 400) {
|
||||||
|
throw new WeatherApiError('Invalid or missing location parameters', 'BAD_REQUEST')
|
||||||
|
}
|
||||||
|
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
export const VOICE_MEMO_MAX_DURATION_SEC = 60
|
||||||
|
export const VOICE_MEMO_MAX_BLOB_BYTES = 800_000
|
||||||
|
|
||||||
|
const MIME_CANDIDATES = [
|
||||||
|
'audio/webm;codecs=opus',
|
||||||
|
'audio/webm',
|
||||||
|
'audio/mp4',
|
||||||
|
'audio/ogg;codecs=opus'
|
||||||
|
]
|
||||||
|
|
||||||
|
export function pickMediaRecorderMimeType(): string | undefined {
|
||||||
|
if (typeof MediaRecorder === 'undefined') return undefined
|
||||||
|
for (const mime of MIME_CANDIDATES) {
|
||||||
|
if (MediaRecorder.isTypeSupported(mime)) return mime
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blobToAudioDataUrl(blob: Blob): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => resolve(String(reader.result))
|
||||||
|
reader.onerror = () => reject(new Error('audio_read_failed'))
|
||||||
|
reader.readAsDataURL(blob)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatVoiceDuration(seconds: number): string {
|
||||||
|
const s = Math.max(0, Math.floor(seconds))
|
||||||
|
const m = Math.floor(s / 60)
|
||||||
|
const r = s % 60
|
||||||
|
return `${m}:${String(r).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertVoiceMemoBlobSize(blob: Blob): void {
|
||||||
|
if (blob.size > VOICE_MEMO_MAX_BLOB_BYTES) {
|
||||||
|
throw new Error('VOICE_MEMO_TOO_LARGE')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
cameraErrorKeyFromDomException,
|
||||||
|
isCameraApiSupported,
|
||||||
|
probeCameraAvailability
|
||||||
|
} from './cameraAvailability.js'
|
||||||
|
|
||||||
|
describe('cameraAvailability', () => {
|
||||||
|
it('detects missing camera API', () => {
|
||||||
|
const nav = { mediaDevices: undefined }
|
||||||
|
vi.stubGlobal('navigator', nav)
|
||||||
|
expect(isCameraApiSupported()).toBe(false)
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns none when no videoinput devices', async () => {
|
||||||
|
vi.stubGlobal('navigator', {
|
||||||
|
mediaDevices: {
|
||||||
|
getUserMedia: vi.fn(),
|
||||||
|
enumerateDevices: vi.fn().mockResolvedValue([
|
||||||
|
{ kind: 'audioinput', deviceId: 'a1', label: '', groupId: '' }
|
||||||
|
])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await expect(probeCameraAvailability()).resolves.toBe('none')
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns available when a videoinput exists', async () => {
|
||||||
|
vi.stubGlobal('navigator', {
|
||||||
|
mediaDevices: {
|
||||||
|
getUserMedia: vi.fn(),
|
||||||
|
enumerateDevices: vi.fn().mockResolvedValue([
|
||||||
|
{ kind: 'videoinput', deviceId: 'v1', label: '', groupId: '' }
|
||||||
|
])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await expect(probeCameraAvailability()).resolves.toBe('available')
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps NotFoundError to no-camera i18n key', () => {
|
||||||
|
expect(cameraErrorKeyFromDomException(new DOMException('', 'NotFoundError'))).toBe(
|
||||||
|
'logs.live_photo_no_camera'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
export type CameraAvailability = 'available' | 'none' | 'unsupported'
|
||||||
|
|
||||||
|
/** Whether the browser exposes camera APIs at all. */
|
||||||
|
export function isCameraApiSupported(): boolean {
|
||||||
|
return typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getUserMedia
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-effort probe for at least one video input device (no permission prompt). */
|
||||||
|
export async function probeCameraAvailability(): Promise<CameraAvailability> {
|
||||||
|
if (!isCameraApiSupported()) return 'unsupported'
|
||||||
|
if (!navigator.mediaDevices?.enumerateDevices) {
|
||||||
|
// Cannot list devices; defer to getUserMedia attempt in the capture UI.
|
||||||
|
return 'available'
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const devices = await navigator.mediaDevices.enumerateDevices()
|
||||||
|
if (devices.some((d) => d.kind === 'videoinput')) return 'available'
|
||||||
|
return 'none'
|
||||||
|
} catch {
|
||||||
|
return 'none'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cameraErrorKeyFromDomException(err: unknown): string {
|
||||||
|
const name = err instanceof DOMException ? err.name : ''
|
||||||
|
if (name === 'NotFoundError' || name === 'OverconstrainedError') {
|
||||||
|
return 'logs.live_photo_no_camera'
|
||||||
|
}
|
||||||
|
if (name === 'NotAllowedError' || name === 'NotReadableError' || name === 'SecurityError') {
|
||||||
|
return 'logs.live_photo_camera_denied'
|
||||||
|
}
|
||||||
|
return 'logs.live_photo_camera_unavailable'
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { hashEntryForSigning } from './entryCanonicalHash.js'
|
||||||
|
|
||||||
|
describe('hashEntryForSigning', () => {
|
||||||
|
it('excludes aiSummary fields from the signing hash', async () => {
|
||||||
|
const base = {
|
||||||
|
date: '2026-06-03',
|
||||||
|
dayOfTravel: '1',
|
||||||
|
departure: 'A',
|
||||||
|
destination: 'B',
|
||||||
|
freshwater: { morning: 0, refilled: 0, evening: 0, consumption: 0 },
|
||||||
|
fuel: { morning: 0, refilled: 0, evening: 0, consumption: 0 },
|
||||||
|
events: []
|
||||||
|
}
|
||||||
|
|
||||||
|
const withoutSummary = await hashEntryForSigning(base)
|
||||||
|
const withSummary = await hashEntryForSigning({
|
||||||
|
...base,
|
||||||
|
aiSummary: 'A calm day at sea.',
|
||||||
|
aiSummaryGeneratedAt: '2026-06-03T12:00:00.000Z'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(withSummary).toBe(withoutSummary)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
const SIGNATURE_KEYS = new Set(['signSkipper', 'signCrew'])
|
const SIGNATURE_KEYS = new Set(['signSkipper', 'signCrew'])
|
||||||
|
const AI_SUMMARY_KEYS = new Set(['aiSummary', 'aiSummaryGeneratedAt'])
|
||||||
|
|
||||||
function sortEventsByTime(items: unknown[]): unknown[] {
|
function sortEventsByTime(items: unknown[]): unknown[] {
|
||||||
return [...items]
|
return [...items]
|
||||||
@@ -25,7 +26,7 @@ function sortValue(value: unknown, parentKey?: string): unknown {
|
|||||||
const obj = value as Record<string, unknown>
|
const obj = value as Record<string, unknown>
|
||||||
const sorted: Record<string, unknown> = {}
|
const sorted: Record<string, unknown> = {}
|
||||||
for (const key of Object.keys(obj).sort()) {
|
for (const key of Object.keys(obj).sort()) {
|
||||||
if (SIGNATURE_KEYS.has(key)) continue
|
if (SIGNATURE_KEYS.has(key) || AI_SUMMARY_KEYS.has(key)) continue
|
||||||
sorted[key] = sortValue(obj[key], key)
|
sorted[key] = sortValue(obj[key], key)
|
||||||
}
|
}
|
||||||
return sorted
|
return sorted
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
liveSogRemark,
|
liveSogRemark,
|
||||||
parseLiveCommentRemark,
|
parseLiveCommentRemark,
|
||||||
livePhotoRemark,
|
livePhotoRemark,
|
||||||
|
liveVoiceRemark,
|
||||||
|
parseLiveVoiceRemark,
|
||||||
parseLiveSailsRemark
|
parseLiveSailsRemark
|
||||||
} from './liveEventCodes.js'
|
} from './liveEventCodes.js'
|
||||||
import { formatEventSummary } from './formatEventSummary.js'
|
import { formatEventSummary } from './formatEventSummary.js'
|
||||||
@@ -19,8 +21,8 @@ const t = (key: string, opts?: Record<string, unknown>) => {
|
|||||||
'logs.live_cast_off': 'Cast off',
|
'logs.live_cast_off': 'Cast off',
|
||||||
'logs.live_moor': 'Moor',
|
'logs.live_moor': 'Moor',
|
||||||
'logs.live_sails': `Sails: ${opts?.sails ?? ''}`,
|
'logs.live_sails': `Sails: ${opts?.sails ?? ''}`,
|
||||||
'logs.live_fix': 'Fix',
|
'logs.live_position': 'Position',
|
||||||
'logs.live_fix_coords': `Fix ${opts?.lat}, ${opts?.lng}`,
|
'logs.live_position_coords': `Position ${opts?.lat}, ${opts?.lng}`,
|
||||||
'logs.live_event_generic': 'Event',
|
'logs.live_event_generic': 'Event',
|
||||||
'logs.live_temp_entry': `Temperature ${opts?.temp} °C`,
|
'logs.live_temp_entry': `Temperature ${opts?.temp} °C`,
|
||||||
'logs.live_pressure_entry': `Pressure ${opts?.value} hPa`,
|
'logs.live_pressure_entry': `Pressure ${opts?.value} hPa`,
|
||||||
@@ -28,6 +30,7 @@ const t = (key: string, opts?: Record<string, unknown>) => {
|
|||||||
'logs.live_wind_entry': `Wind ${opts?.value}`,
|
'logs.live_wind_entry': `Wind ${opts?.value}`,
|
||||||
'logs.live_photo_entry': `Photo: ${opts?.caption}`,
|
'logs.live_photo_entry': `Photo: ${opts?.caption}`,
|
||||||
'logs.live_photo_entry_plain': 'Photo captured',
|
'logs.live_photo_entry_plain': 'Photo captured',
|
||||||
|
'logs.live_voice_entry_plain': 'Voice memo',
|
||||||
'logs.live_course_entry': `Course ${opts?.course}`,
|
'logs.live_course_entry': `Course ${opts?.course}`,
|
||||||
'logs.live_sog_entry': `SOG ${opts?.speed} kn`,
|
'logs.live_sog_entry': `SOG ${opts?.speed} kn`,
|
||||||
'logs.live_stw_entry': `STW ${opts?.speed} kn`,
|
'logs.live_stw_entry': `STW ${opts?.speed} kn`,
|
||||||
@@ -59,6 +62,12 @@ describe('liveEventCodes', () => {
|
|||||||
expect(parseLiveSailsRemark(liveSailsRemark('Main + Genoa'))).toBe('Main + Genoa')
|
expect(parseLiveSailsRemark(liveSailsRemark('Main + Genoa'))).toBe('Main + Genoa')
|
||||||
expect(parseLiveCommentRemark(liveCommentRemark('Wind dreht'))).toBe('Wind dreht')
|
expect(parseLiveCommentRemark(liveCommentRemark('Wind dreht'))).toBe('Wind dreht')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('parses voice remark with uuid', () => {
|
||||||
|
const id = 'a1b2c3d4-e5f6-4789-a012-3456789abcde'
|
||||||
|
expect(parseLiveVoiceRemark(liveVoiceRemark(id))).toBe(id)
|
||||||
|
expect(parseLiveVoiceRemark('__live:voice:not-a-uuid')).toBeNull()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('formatEventSummary', () => {
|
describe('formatEventSummary', () => {
|
||||||
@@ -76,14 +85,14 @@ describe('formatEventSummary', () => {
|
|||||||
expect(formatEventSummary(event, t)).toBe('Sails: Main + Genoa')
|
expect(formatEventSummary(event, t)).toBe('Sails: Main + Genoa')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('formats fix with coordinates', () => {
|
it('formats position with coordinates', () => {
|
||||||
const event = normalizeLogEvent({
|
const event = normalizeLogEvent({
|
||||||
time: '09:00',
|
time: '09:00',
|
||||||
remarks: LIVE_EVENT_CODES.FIX,
|
remarks: LIVE_EVENT_CODES.POSITION,
|
||||||
gpsLat: '54.323000',
|
gpsLat: '54.323000',
|
||||||
gpsLng: '10.145000'
|
gpsLng: '10.145000'
|
||||||
})
|
})
|
||||||
expect(formatEventSummary(event, t)).toBe('Fix 54.323000, 10.145000')
|
expect(formatEventSummary(event, t)).toBe('Position 54.323000, 10.145000')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('formats pressure entry', () => {
|
it('formats pressure entry', () => {
|
||||||
@@ -130,4 +139,10 @@ describe('formatEventSummary', () => {
|
|||||||
})
|
})
|
||||||
expect(formatEventSummary(captioned, t)).toBe('Photo: Mastbruch')
|
expect(formatEventSummary(captioned, t)).toBe('Photo: Mastbruch')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('formats voice memo entry', () => {
|
||||||
|
const id = 'a1b2c3d4-e5f6-4789-a012-3456789abcde'
|
||||||
|
const event = normalizeLogEvent({ time: '12:00', remarks: liveVoiceRemark(id) })
|
||||||
|
expect(formatEventSummary(event, t)).toBe('Voice memo')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import type { TFunction } from 'i18next'
|
import type { TFunction } from 'i18next'
|
||||||
import type { LogEventPayload } from './logEntryPayload.js'
|
import type { LogEventPayload } from './logEntryPayload.js'
|
||||||
import {
|
import {
|
||||||
|
isManualPositionEventCode,
|
||||||
LIVE_EVENT_CODES,
|
LIVE_EVENT_CODES,
|
||||||
parseLiveCommentRemark,
|
parseLiveCommentRemark,
|
||||||
parseLiveFuelRemark,
|
parseLiveFuelRemark,
|
||||||
parseLivePhotoRemark,
|
parseLivePhotoRemark,
|
||||||
|
parseLiveVoiceRemark,
|
||||||
parseLivePrecipRemark,
|
parseLivePrecipRemark,
|
||||||
parseLiveSailsRemark,
|
parseLiveSailsRemark,
|
||||||
parseLiveSogRemark,
|
parseLiveSogRemark,
|
||||||
@@ -34,6 +36,11 @@ export function formatEventSummary(event: LogEventPayload, t: TFunction): string
|
|||||||
: t('logs.live_photo_entry_plain')
|
: t('logs.live_photo_entry_plain')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const voiceId = parseLiveVoiceRemark(code)
|
||||||
|
if (voiceId) {
|
||||||
|
return t('logs.live_voice_entry_plain')
|
||||||
|
}
|
||||||
|
|
||||||
const temp = parseLiveTempRemark(code)
|
const temp = parseLiveTempRemark(code)
|
||||||
if (temp) return t('logs.live_temp_entry', { temp })
|
if (temp) return t('logs.live_temp_entry', { temp })
|
||||||
|
|
||||||
@@ -52,16 +59,16 @@ export function formatEventSummary(event: LogEventPayload, t: TFunction): string
|
|||||||
const stw = parseLiveStwRemark(code)
|
const stw = parseLiveStwRemark(code)
|
||||||
if (stw) return t('logs.live_stw_entry', { speed: stw })
|
if (stw) return t('logs.live_stw_entry', { speed: stw })
|
||||||
|
|
||||||
if (code === LIVE_EVENT_CODES.FIX || code === LIVE_EVENT_CODES.AUTO_POSITION) {
|
if (isManualPositionEventCode(code) || code === LIVE_EVENT_CODES.AUTO_POSITION) {
|
||||||
if (event.gpsLat && event.gpsLng) {
|
if (event.gpsLat && event.gpsLng) {
|
||||||
const label = code === LIVE_EVENT_CODES.AUTO_POSITION
|
const label = code === LIVE_EVENT_CODES.AUTO_POSITION
|
||||||
? t('logs.live_auto_position')
|
? t('logs.live_auto_position')
|
||||||
: t('logs.live_fix')
|
: t('logs.live_position')
|
||||||
return `${label} ${event.gpsLat}, ${event.gpsLng}`
|
return `${label} ${event.gpsLat}, ${event.gpsLng}`
|
||||||
}
|
}
|
||||||
return code === LIVE_EVENT_CODES.AUTO_POSITION
|
return code === LIVE_EVENT_CODES.AUTO_POSITION
|
||||||
? t('logs.live_auto_position')
|
? t('logs.live_auto_position')
|
||||||
: t('logs.live_fix')
|
: t('logs.live_position')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (code === LIVE_EVENT_CODES.COURSE && event.mgk) {
|
if (code === LIVE_EVENT_CODES.COURSE && event.mgk) {
|
||||||
|
|||||||
@@ -7,7 +7,4 @@ export function computeFuelPerMotorHour(
|
|||||||
return Number((fuelConsumptionL / motorHours).toFixed(2))
|
return Number((fuelConsumptionL / motorHours).toFixed(2))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatFuelPerMotorHour(value: number | null | undefined): string {
|
export { formatFuelPerMotorHour } from './numberFormat.js'
|
||||||
if (value == null) return '—'
|
|
||||||
return Number.isInteger(value) ? String(value) : value.toFixed(2)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,12 +1,29 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import {
|
import {
|
||||||
|
classifyGpsAccuracyMeters,
|
||||||
|
formatGpsAccuracyMeters,
|
||||||
|
geolocationErrorI18nKey,
|
||||||
|
GEOLOCATION_LIVE_INTRO_STORAGE_KEY,
|
||||||
getCurrentPosition,
|
getCurrentPosition,
|
||||||
|
getGeolocationErrorReason,
|
||||||
|
hasSeenGeolocationLiveIntro,
|
||||||
|
markGeolocationLiveIntroSeen,
|
||||||
normalizeGpsCoordinates,
|
normalizeGpsCoordinates,
|
||||||
parseGpsCoordinate,
|
parseGpsCoordinate,
|
||||||
queryGeolocationPermission
|
queryGeolocationPermission
|
||||||
} from './geolocation.js'
|
} from './geolocation.js'
|
||||||
|
|
||||||
describe('geolocation helpers', () => {
|
describe('geolocation helpers', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.removeItem(GEOLOCATION_LIVE_INTRO_STORAGE_KEY)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tracks Live-Log geolocation intro in localStorage', () => {
|
||||||
|
expect(hasSeenGeolocationLiveIntro()).toBe(false)
|
||||||
|
markGeolocationLiveIntroSeen()
|
||||||
|
expect(hasSeenGeolocationLiveIntro()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
it('parses coordinates with comma decimals', () => {
|
it('parses coordinates with comma decimals', () => {
|
||||||
expect(parseGpsCoordinate('54,123')).toBeCloseTo(54.123)
|
expect(parseGpsCoordinate('54,123')).toBeCloseTo(54.123)
|
||||||
})
|
})
|
||||||
@@ -50,7 +67,7 @@ describe('geolocation helpers', () => {
|
|||||||
geolocation: {
|
geolocation: {
|
||||||
getCurrentPosition: (success: PositionCallback) => {
|
getCurrentPosition: (success: PositionCallback) => {
|
||||||
success({
|
success({
|
||||||
coords: { latitude: 59.91, longitude: 10.75, speed: 2.5 }
|
coords: { latitude: 59.91, longitude: 10.75, speed: 2.5, accuracy: 12 }
|
||||||
} as GeolocationPosition)
|
} as GeolocationPosition)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,10 +76,36 @@ describe('geolocation helpers', () => {
|
|||||||
await expect(getCurrentPosition({ timeoutMs: 1000, enableHighAccuracy: false })).resolves.toEqual({
|
await expect(getCurrentPosition({ timeoutMs: 1000, enableHighAccuracy: false })).resolves.toEqual({
|
||||||
lat: '59.910000',
|
lat: '59.910000',
|
||||||
lng: '10.750000',
|
lng: '10.750000',
|
||||||
speedKn: 4.9
|
speedKn: 4.9,
|
||||||
|
accuracyM: 12,
|
||||||
|
signalQuality: 'excellent'
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('formats GPS accuracy for display', () => {
|
||||||
|
expect(formatGpsAccuracyMeters(12.4)).toBe('12')
|
||||||
|
expect(formatGpsAccuracyMeters(87)).toBe('87')
|
||||||
|
expect(formatGpsAccuracyMeters(105)).toBe('110')
|
||||||
|
expect(formatGpsAccuracyMeters(247)).toBe('250')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('classifies GPS accuracy into signal quality', () => {
|
||||||
|
expect(classifyGpsAccuracyMeters(8)).toBe('excellent')
|
||||||
|
expect(classifyGpsAccuracyMeters(30)).toBe('good')
|
||||||
|
expect(classifyGpsAccuracyMeters(80)).toBe('fair')
|
||||||
|
expect(classifyGpsAccuracyMeters(250)).toBe('poor')
|
||||||
|
expect(classifyGpsAccuracyMeters(null)).toBe('unknown')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps GeolocationPositionError codes to reasons', () => {
|
||||||
|
expect(getGeolocationErrorReason({ code: 1 } as GeolocationPositionError)).toBe('permission_denied')
|
||||||
|
expect(getGeolocationErrorReason({ code: 2 } as GeolocationPositionError)).toBe('position_unavailable')
|
||||||
|
expect(getGeolocationErrorReason({ code: 3 } as GeolocationPositionError)).toBe('timeout')
|
||||||
|
expect(getGeolocationErrorReason(new Error('geolocation_timeout'))).toBe('timeout')
|
||||||
|
expect(getGeolocationErrorReason(new Error('geolocation_unavailable'))).toBe('unavailable')
|
||||||
|
expect(geolocationErrorI18nKey('permission_denied')).toBe('logs.gps_permission_denied')
|
||||||
|
})
|
||||||
|
|
||||||
it('reads permission state when supported', async () => {
|
it('reads permission state when supported', async () => {
|
||||||
vi.stubGlobal('navigator', {
|
vi.stubGlobal('navigator', {
|
||||||
geolocation: {},
|
geolocation: {},
|
||||||
|
|||||||
@@ -1,17 +1,80 @@
|
|||||||
|
import {
|
||||||
|
formatAppCoordinate,
|
||||||
|
formatCanonicalCoordinate,
|
||||||
|
formatGpsAccuracyMeters,
|
||||||
|
parseAppDecimal
|
||||||
|
} from './numberFormat.js'
|
||||||
|
|
||||||
const MPS_TO_KNOTS = 1.9438444924406
|
const MPS_TO_KNOTS = 1.9438444924406
|
||||||
|
|
||||||
/** Extra ms beyond the native timeout so hung browsers still reject. */
|
/** Extra ms beyond the native timeout so hung browsers still reject. */
|
||||||
const TIMEOUT_GRACE_MS = 750
|
const TIMEOUT_GRACE_MS = 750
|
||||||
|
|
||||||
|
/** Estimated fix quality from browser accuracy (metres). Real satellite count is not exposed to web apps. */
|
||||||
|
export type GpsSignalQuality = 'excellent' | 'good' | 'fair' | 'poor' | 'unknown'
|
||||||
|
|
||||||
export interface GeoCoordinates {
|
export interface GeoCoordinates {
|
||||||
lat: string
|
lat: string
|
||||||
lng: string
|
lng: string
|
||||||
/** SOG from GPS when available (kn), otherwise null. */
|
/** SOG from GPS when available (kn), otherwise null. */
|
||||||
speedKn: number | null
|
speedKn: number | null
|
||||||
|
/** Estimated horizontal accuracy in metres, when reported by the browser. */
|
||||||
|
accuracyM: number | null
|
||||||
|
/** Derived signal quality indicator for UI hints. */
|
||||||
|
signalQuality: GpsSignalQuality
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Classifies GPS fix quality from reported accuracy (lower metres = better). */
|
||||||
|
export function classifyGpsAccuracyMeters(accuracyM: number | null | undefined): GpsSignalQuality {
|
||||||
|
if (accuracyM == null || !Number.isFinite(accuracyM) || accuracyM < 0) return 'unknown'
|
||||||
|
if (accuracyM <= 15) return 'excellent'
|
||||||
|
if (accuracyM <= 40) return 'good'
|
||||||
|
if (accuracyM <= 100) return 'fair'
|
||||||
|
return 'poor'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gpsQualityI18nKey(quality: GpsSignalQuality): string {
|
||||||
|
return `logs.gps_quality_${quality}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GeolocationPermissionState = PermissionState | 'unsupported'
|
export type GeolocationPermissionState = PermissionState | 'unsupported'
|
||||||
|
|
||||||
|
export type GeolocationErrorReason =
|
||||||
|
| 'unavailable'
|
||||||
|
| 'timeout'
|
||||||
|
| 'permission_denied'
|
||||||
|
| 'position_unavailable'
|
||||||
|
| 'unknown'
|
||||||
|
|
||||||
|
/** Maps browser / wrapper errors to a stable reason for i18n. */
|
||||||
|
export function getGeolocationErrorReason(error: unknown): GeolocationErrorReason {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
if (error.message === 'geolocation_unavailable') return 'unavailable'
|
||||||
|
if (error.message === 'geolocation_timeout') return 'timeout'
|
||||||
|
}
|
||||||
|
const code = (error as GeolocationPositionError | undefined)?.code
|
||||||
|
if (code === 1) return 'permission_denied'
|
||||||
|
if (code === 2) return 'position_unavailable'
|
||||||
|
if (code === 3) return 'timeout'
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** i18n key (full path, e.g. logs.gps_timeout) for a geolocation failure reason. */
|
||||||
|
export function geolocationErrorI18nKey(reason: GeolocationErrorReason): string {
|
||||||
|
switch (reason) {
|
||||||
|
case 'unavailable':
|
||||||
|
return 'logs.gps_unavailable'
|
||||||
|
case 'timeout':
|
||||||
|
return 'logs.gps_timeout'
|
||||||
|
case 'permission_denied':
|
||||||
|
return 'logs.gps_permission_denied'
|
||||||
|
case 'position_unavailable':
|
||||||
|
return 'logs.gps_position_unavailable'
|
||||||
|
default:
|
||||||
|
return 'logs.gps_failed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface GetPositionOptions {
|
export interface GetPositionOptions {
|
||||||
timeoutMs?: number
|
timeoutMs?: number
|
||||||
/** Manual fixes may use high accuracy; background auto-position should not. */
|
/** Manual fixes may use high accuracy; background auto-position should not. */
|
||||||
@@ -19,11 +82,10 @@ export interface GetPositionOptions {
|
|||||||
maximumAge?: number
|
maximumAge?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { formatGpsAccuracyMeters }
|
||||||
|
|
||||||
export function parseGpsCoordinate(value: string): number | null {
|
export function parseGpsCoordinate(value: string): number | null {
|
||||||
const trimmed = value.trim()
|
return parseAppDecimal(value.trim())
|
||||||
if (!trimmed) return null
|
|
||||||
const n = parseFloat(trimmed.replace(',', '.'))
|
|
||||||
return Number.isFinite(n) ? n : null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Validates lat/lng and returns normalized strings for storage, or null. */
|
/** Validates lat/lng and returns normalized strings for storage, or null. */
|
||||||
@@ -35,7 +97,26 @@ export function normalizeGpsCoordinates(
|
|||||||
const lngN = parseGpsCoordinate(lng)
|
const lngN = parseGpsCoordinate(lng)
|
||||||
if (latN == null || lngN == null) return null
|
if (latN == null || lngN == null) return null
|
||||||
if (latN < -90 || latN > 90 || lngN < -180 || lngN > 180) return null
|
if (latN < -90 || latN > 90 || lngN < -180 || lngN > 180) return null
|
||||||
return { lat: latN.toFixed(6), lng: lngN.toFixed(6) }
|
return { lat: formatCanonicalCoordinate(latN), lng: formatCanonicalCoordinate(lngN) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** localStorage: user has seen the Live-Log geolocation intro (allow or dismiss). */
|
||||||
|
export const GEOLOCATION_LIVE_INTRO_STORAGE_KEY = 'kdb_geolocation_live_intro_seen'
|
||||||
|
|
||||||
|
export function hasSeenGeolocationLiveIntro(): boolean {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(GEOLOCATION_LIVE_INTRO_STORAGE_KEY) === '1'
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markGeolocationLiveIntroSeen(): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(GEOLOCATION_LIVE_INTRO_STORAGE_KEY, '1')
|
||||||
|
} catch {
|
||||||
|
// Private mode / quota — non-fatal
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function queryGeolocationPermission(): Promise<GeolocationPermissionState> {
|
export async function queryGeolocationPermission(): Promise<GeolocationPermissionState> {
|
||||||
@@ -65,10 +146,15 @@ function positionFromGeolocationPosition(pos: GeolocationPosition): GeoCoordinat
|
|||||||
const speedKn = pos.coords.speed != null && Number.isFinite(pos.coords.speed)
|
const speedKn = pos.coords.speed != null && Number.isFinite(pos.coords.speed)
|
||||||
? Number((pos.coords.speed * MPS_TO_KNOTS).toFixed(1))
|
? Number((pos.coords.speed * MPS_TO_KNOTS).toFixed(1))
|
||||||
: null
|
: null
|
||||||
|
const accuracyM = pos.coords.accuracy != null && Number.isFinite(pos.coords.accuracy)
|
||||||
|
? pos.coords.accuracy
|
||||||
|
: null
|
||||||
return {
|
return {
|
||||||
lat: pos.coords.latitude.toFixed(6),
|
lat: formatAppCoordinate(pos.coords.latitude),
|
||||||
lng: pos.coords.longitude.toFixed(6),
|
lng: formatAppCoordinate(pos.coords.longitude),
|
||||||
speedKn
|
speedKn,
|
||||||
|
accuracyM,
|
||||||
|
signalQuality: classifyGpsAccuracyMeters(accuracyM)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export const LIVE_EVENT_CODES = {
|
|||||||
MOTOR_STOP: '__live:motor_stop',
|
MOTOR_STOP: '__live:motor_stop',
|
||||||
CAST_OFF: '__live:cast_off',
|
CAST_OFF: '__live:cast_off',
|
||||||
MOOR: '__live:moor',
|
MOOR: '__live:moor',
|
||||||
FIX: '__live:fix',
|
POSITION: '__live:position',
|
||||||
AUTO_POSITION: '__live:auto_position',
|
AUTO_POSITION: '__live:auto_position',
|
||||||
COURSE: '__live:course',
|
COURSE: '__live:course',
|
||||||
WIND: '__live:wind',
|
WIND: '__live:wind',
|
||||||
@@ -13,6 +13,9 @@ export const LIVE_EVENT_CODES = {
|
|||||||
VISIBILITY: '__live:visibility'
|
VISIBILITY: '__live:visibility'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
/** @deprecated Stored in older log entries; still recognized when reading events. */
|
||||||
|
export const LEGACY_LIVE_POSITION_REMARK = '__live:fix'
|
||||||
|
|
||||||
export type LiveEventCode = (typeof LIVE_EVENT_CODES)[keyof typeof LIVE_EVENT_CODES]
|
export type LiveEventCode = (typeof LIVE_EVENT_CODES)[keyof typeof LIVE_EVENT_CODES]
|
||||||
|
|
||||||
export function liveSailsRemark(sails: string): string {
|
export function liveSailsRemark(sails: string): string {
|
||||||
@@ -50,6 +53,21 @@ export function parseLivePhotoRemark(remarks: string): string | null {
|
|||||||
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
|
return remarks.startsWith(prefix) ? remarks.slice(prefix.length) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VOICE_UUID_RE =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||||
|
|
||||||
|
export function liveVoiceRemark(audioId: string): string {
|
||||||
|
return `__live:voice:${audioId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseLiveVoiceRemark(remarks: string): string | null {
|
||||||
|
const trimmed = remarks.trim()
|
||||||
|
const prefix = '__live:voice:'
|
||||||
|
if (!trimmed.startsWith(prefix)) return null
|
||||||
|
const id = trimmed.slice(prefix.length)
|
||||||
|
return VOICE_UUID_RE.test(id) ? id : null
|
||||||
|
}
|
||||||
|
|
||||||
export function liveSogRemark(speedKn: string): string {
|
export function liveSogRemark(speedKn: string): string {
|
||||||
return `__live:sog:${speedKn}`
|
return `__live:sog:${speedKn}`
|
||||||
}
|
}
|
||||||
@@ -133,27 +151,31 @@ export function getLastAutoPositionMs(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Max age of a logged GPS fix for OpenWeatherMap lookups in live log. */
|
/** Max age of a logged position for OpenWeatherMap lookups in live log. */
|
||||||
export const LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS = 6 * 60 * 60 * 1000
|
export const LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS = 6 * 60 * 60 * 1000
|
||||||
|
|
||||||
export type LiveLogPositionSource = 'fix' | 'auto_position'
|
export type LiveLogPositionSource = 'position' | 'auto_position'
|
||||||
|
|
||||||
export interface LiveLogPositionFix {
|
export interface LiveLogPosition {
|
||||||
lat: string
|
lat: string
|
||||||
lng: string
|
lng: string
|
||||||
loggedAtMs: number
|
loggedAtMs: number
|
||||||
source: LiveLogPositionSource
|
source: LiveLogPositionSource
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPositionEventCode(code: string): boolean {
|
export function isManualPositionEventCode(code: string): boolean {
|
||||||
return code === LIVE_EVENT_CODES.FIX || code === LIVE_EVENT_CODES.AUTO_POSITION
|
return code === LIVE_EVENT_CODES.POSITION || code === LEGACY_LIVE_POSITION_REMARK
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Latest FIX or auto-position event with GPS coordinates (any age). */
|
function isPositionEventCode(code: string): boolean {
|
||||||
export function getLatestPositionFix(
|
return isManualPositionEventCode(code) || code === LIVE_EVENT_CODES.AUTO_POSITION
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Latest manual or auto-position event with GPS coordinates (any age). */
|
||||||
|
export function getLatestLoggedPosition(
|
||||||
events: Array<{ remarks: string; time: string; gpsLat?: string; gpsLng?: string }>,
|
events: Array<{ remarks: string; time: string; gpsLat?: string; gpsLng?: string }>,
|
||||||
entryDate: string
|
entryDate: string
|
||||||
): LiveLogPositionFix | null {
|
): LiveLogPosition | null {
|
||||||
for (let i = events.length - 1; i >= 0; i--) {
|
for (let i = events.length - 1; i >= 0; i--) {
|
||||||
const event = events[i]
|
const event = events[i]
|
||||||
const code = event.remarks.trim()
|
const code = event.remarks.trim()
|
||||||
@@ -167,20 +189,20 @@ export function getLatestPositionFix(
|
|||||||
lat,
|
lat,
|
||||||
lng,
|
lng,
|
||||||
loggedAtMs,
|
loggedAtMs,
|
||||||
source: code === LIVE_EVENT_CODES.FIX ? 'fix' : 'auto_position'
|
source: isManualPositionEventCode(code) ? 'position' : 'auto_position'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GPS fix for weather if logged within `maxAgeMs` (default 6 h). */
|
/** Logged position for weather if recorded within `maxAgeMs` (default 6 h). */
|
||||||
export function getLastPositionFixWithin(
|
export function getLastLoggedPositionWithin(
|
||||||
events: Array<{ remarks: string; time: string; gpsLat?: string; gpsLng?: string }>,
|
events: Array<{ remarks: string; time: string; gpsLat?: string; gpsLng?: string }>,
|
||||||
entryDate: string,
|
entryDate: string,
|
||||||
maxAgeMs: number = LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS,
|
maxAgeMs: number = LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS,
|
||||||
nowMs: number = Date.now()
|
nowMs: number = Date.now()
|
||||||
): LiveLogPositionFix | null {
|
): LiveLogPosition | null {
|
||||||
const latest = getLatestPositionFix(events, entryDate)
|
const latest = getLatestLoggedPosition(events, entryDate)
|
||||||
if (!latest) return null
|
if (!latest) return null
|
||||||
if (nowMs - latest.loggedAtMs > maxAgeMs) return null
|
if (nowMs - latest.loggedAtMs > maxAgeMs) return null
|
||||||
return latest
|
return latest
|
||||||
|
|||||||
@@ -1,54 +1,67 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import {
|
import {
|
||||||
getLastPositionFixWithin,
|
getLastLoggedPositionWithin,
|
||||||
getLatestPositionFix,
|
getLatestLoggedPosition,
|
||||||
|
LEGACY_LIVE_POSITION_REMARK,
|
||||||
LIVE_EVENT_CODES,
|
LIVE_EVENT_CODES,
|
||||||
LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS
|
LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS
|
||||||
} from './liveEventCodes.js'
|
} from './liveEventCodes.js'
|
||||||
|
|
||||||
const entryDate = '2026-06-01'
|
describe('live log position', () => {
|
||||||
|
it('returns latest position with coordinates', () => {
|
||||||
describe('live log position fix', () => {
|
const entryDate = '2026-06-01'
|
||||||
it('returns latest fix with coordinates', () => {
|
|
||||||
const events = [
|
const events = [
|
||||||
{ remarks: LIVE_EVENT_CODES.FIX, time: '08:00', gpsLat: '54.1', gpsLng: '10.2' },
|
{ remarks: LIVE_EVENT_CODES.POSITION, time: '08:00', gpsLat: '54.1', gpsLng: '10.2' },
|
||||||
{ remarks: LIVE_EVENT_CODES.FIX, time: '12:30', gpsLat: '54.2', gpsLng: '10.3' }
|
{ remarks: LIVE_EVENT_CODES.POSITION, time: '12:30', gpsLat: '54.2', gpsLng: '10.3' }
|
||||||
]
|
]
|
||||||
const fix = getLatestPositionFix(events, entryDate)
|
const position = getLatestLoggedPosition(events, entryDate)
|
||||||
expect(fix?.lat).toBe('54.2')
|
expect(position?.lat).toBe('54.2')
|
||||||
expect(fix?.source).toBe('fix')
|
expect(position?.source).toBe('position')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('accepts auto-position with GPS', () => {
|
it('reads legacy __live:fix remarks', () => {
|
||||||
|
const entryDate = '2026-06-01'
|
||||||
|
const events = [
|
||||||
|
{ remarks: LEGACY_LIVE_POSITION_REMARK, time: '09:00', gpsLat: '54.5', gpsLng: '10.5' }
|
||||||
|
]
|
||||||
|
const position = getLatestLoggedPosition(events, entryDate)
|
||||||
|
expect(position?.lat).toBe('54.5')
|
||||||
|
expect(position?.source).toBe('position')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prefers auto-position source when applicable', () => {
|
||||||
|
const entryDate = '2026-06-01'
|
||||||
const events = [
|
const events = [
|
||||||
{
|
{
|
||||||
remarks: LIVE_EVENT_CODES.AUTO_POSITION,
|
remarks: LIVE_EVENT_CODES.AUTO_POSITION,
|
||||||
time: '14:00',
|
time: '14:00',
|
||||||
gpsLat: '55.0',
|
gpsLat: '54.3',
|
||||||
gpsLng: '11.0'
|
gpsLng: '10.4'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
expect(getLatestPositionFix(events, entryDate)?.source).toBe('auto_position')
|
expect(getLatestLoggedPosition(events, entryDate)?.source).toBe('auto_position')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects fix older than max age for weather', () => {
|
it('rejects position older than max age for weather', () => {
|
||||||
const noon = new Date(`${entryDate}T12:00:00`).getTime()
|
const entryDate = '2026-06-01'
|
||||||
|
const noon = new Date('2026-06-01T12:00:00').getTime()
|
||||||
const events = [
|
const events = [
|
||||||
{ remarks: LIVE_EVENT_CODES.FIX, time: '05:00', gpsLat: '54.0', gpsLng: '10.0' }
|
{ remarks: LIVE_EVENT_CODES.POSITION, time: '05:00', gpsLat: '54.0', gpsLng: '10.0' }
|
||||||
]
|
]
|
||||||
expect(
|
expect(
|
||||||
getLastPositionFixWithin(events, entryDate, LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS, noon)
|
getLastLoggedPositionWithin(events, entryDate, LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS, noon)
|
||||||
).toBeNull()
|
).toBeNull()
|
||||||
expect(getLatestPositionFix(events, entryDate)).not.toBeNull()
|
expect(getLatestLoggedPosition(events, entryDate)).not.toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('accepts fix within six hours', () => {
|
it('accepts position within six hours', () => {
|
||||||
const noon = new Date(`${entryDate}T12:00:00`).getTime()
|
const entryDate = '2026-06-01'
|
||||||
|
const noon = new Date('2026-06-01T12:00:00').getTime()
|
||||||
const events = [
|
const events = [
|
||||||
{ remarks: LIVE_EVENT_CODES.FIX, time: '07:00', gpsLat: '54.0', gpsLng: '10.0' }
|
{ remarks: LIVE_EVENT_CODES.POSITION, time: '07:00', gpsLat: '54.0', gpsLng: '10.0' }
|
||||||
]
|
]
|
||||||
expect(
|
expect(
|
||||||
getLastPositionFixWithin(events, entryDate, LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS, noon)
|
getLastLoggedPositionWithin(events, entryDate, LIVE_LOG_WEATHER_POSITION_MAX_AGE_MS, noon)
|
||||||
).not.toBeNull()
|
).not.toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
buildLogEntryPayload,
|
buildLogEntryPayload,
|
||||||
hasUnsavedEventDraft,
|
hasUnsavedEventDraft,
|
||||||
isLogEventDraftEmpty,
|
isLogEventDraftEmpty,
|
||||||
|
localDateString,
|
||||||
normalizeLogEvent,
|
normalizeLogEvent,
|
||||||
type LogEventPayload
|
type LogEventPayload
|
||||||
} from './logEntryPayload.js'
|
} from './logEntryPayload.js'
|
||||||
@@ -13,6 +14,14 @@ const emptyDraft = (): LogEventPayload =>
|
|||||||
const filledDraft = (): LogEventPayload =>
|
const filledDraft = (): LogEventPayload =>
|
||||||
normalizeLogEvent({ time: '12:34', remarks: 'Wind dreht' })
|
normalizeLogEvent({ time: '12:34', remarks: 'Wind dreht' })
|
||||||
|
|
||||||
|
describe('localDateString', () => {
|
||||||
|
it('uses local calendar date, not UTC', () => {
|
||||||
|
const date = new Date(2026, 5, 4, 1, 30, 0)
|
||||||
|
expect(localDateString(date)).toBe('2026-06-04')
|
||||||
|
expect(date.toISOString().substring(0, 10)).toBe('2026-06-03')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('logEntryPayload event drafts', () => {
|
describe('logEntryPayload event drafts', () => {
|
||||||
it('treats time-only draft as empty', () => {
|
it('treats time-only draft as empty', () => {
|
||||||
expect(isLogEventDraftEmpty(emptyDraft())).toBe(true)
|
expect(isLogEventDraftEmpty(emptyDraft())).toBe(true)
|
||||||
|
|||||||
@@ -22,6 +22,15 @@ export interface LogEventPayload {
|
|||||||
gpsLat: string
|
gpsLat: string
|
||||||
gpsLng: string
|
gpsLng: string
|
||||||
remarks: string
|
remarks: string
|
||||||
|
creatorId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calendar date YYYY-MM-DD in local timezone (matches logbook entry `date` field). */
|
||||||
|
export function localDateString(date: Date = new Date()): string {
|
||||||
|
const y = date.getFullYear()
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const d = String(date.getDate()).padStart(2, '0')
|
||||||
|
return `${y}-${m}-${d}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Local time as HH:MM (24-hour). */
|
/** Local time as HH:MM (24-hour). */
|
||||||
@@ -77,7 +86,7 @@ export function joinTimeHHMM(hours: string, minutes: string): string {
|
|||||||
const LOG_EVENT_FIELDS: (keyof LogEventPayload)[] = [
|
const LOG_EVENT_FIELDS: (keyof LogEventPayload)[] = [
|
||||||
'time', 'mgk', 'rwk', 'windPressure', 'windDirection', 'windStrength', 'seaState',
|
'time', 'mgk', 'rwk', 'windPressure', 'windDirection', 'windStrength', 'seaState',
|
||||||
'visibility', 'weatherIcon', 'current', 'heel', 'sailsOrMotor', 'logReading', 'distance',
|
'visibility', 'weatherIcon', 'current', 'heel', 'sailsOrMotor', 'logReading', 'distance',
|
||||||
'gpsLat', 'gpsLng', 'remarks'
|
'gpsLat', 'gpsLng', 'remarks', 'creatorId'
|
||||||
]
|
]
|
||||||
|
|
||||||
/** Normalize partial/legacy events so all fields are strings (safe for form + save). */
|
/** Normalize partial/legacy events so all fields are strings (safe for form + save). */
|
||||||
@@ -101,10 +110,11 @@ export function normalizeLogEvent(event: Partial<LogEventPayload> | Record<strin
|
|||||||
distance: '',
|
distance: '',
|
||||||
gpsLat: '',
|
gpsLat: '',
|
||||||
gpsLng: '',
|
gpsLng: '',
|
||||||
remarks: ''
|
remarks: '',
|
||||||
|
creatorId: e.creatorId ? String(e.creatorId).trim() : undefined
|
||||||
}
|
}
|
||||||
for (const key of LOG_EVENT_FIELDS) {
|
for (const key of LOG_EVENT_FIELDS) {
|
||||||
if (key === 'time' || key === 'mgk' || key === 'rwk' || key === 'windDirection') continue
|
if (key === 'time' || key === 'mgk' || key === 'rwk' || key === 'windDirection' || key === 'creatorId') continue
|
||||||
normalized[key] = String(e[key] ?? '').trim()
|
normalized[key] = String(e[key] ?? '').trim()
|
||||||
}
|
}
|
||||||
return normalized
|
return normalized
|
||||||
@@ -114,7 +124,7 @@ export function logEventsEqual(a: LogEventPayload, b: LogEventPayload): boolean
|
|||||||
return LOG_EVENT_FIELDS.every((key) => a[key] === b[key])
|
return LOG_EVENT_FIELDS.every((key) => a[key] === b[key])
|
||||||
}
|
}
|
||||||
|
|
||||||
const LOG_EVENT_CONTENT_FIELDS = LOG_EVENT_FIELDS.filter((key) => key !== 'time')
|
const LOG_EVENT_CONTENT_FIELDS = LOG_EVENT_FIELDS.filter((key) => key !== 'time' && key !== 'creatorId')
|
||||||
|
|
||||||
/** Draft with only a time (or empty fields) — not an unsaved log entry change. */
|
/** Draft with only a time (or empty fields) — not an unsaved log entry change. */
|
||||||
export function isLogEventDraftEmpty(event: LogEventPayload): boolean {
|
export function isLogEventDraftEmpty(event: LogEventPayload): boolean {
|
||||||
|
|||||||
@@ -56,10 +56,7 @@ export function emptyTankLevels(morning = 0): TankLevels {
|
|||||||
return { morning, refilled: 0, evening: 0, consumption: 0 }
|
return { morning, refilled: 0, evening: 0, consumption: 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatTankLiters(liters: number): string {
|
export { formatTankLiters } from './numberFormat.js'
|
||||||
if (!Number.isFinite(liters) || liters <= 0) return '0'
|
|
||||||
return Number.isInteger(liters) ? String(liters) : liters.toFixed(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getClosingGreywaterLevel(greywater?: { level?: number } | null): number {
|
export function getClosingGreywaterLevel(greywater?: { level?: number } | null): number {
|
||||||
return Number(greywater?.level) || 0
|
return Number(greywater?.level) || 0
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
formatAppCoordinate,
|
||||||
|
formatAppDecimal,
|
||||||
|
formatGpsAccuracyMeters,
|
||||||
|
formatTankLiters,
|
||||||
|
getNumberFormatSymbols,
|
||||||
|
parseAppDecimal,
|
||||||
|
resolveDeviceLocale
|
||||||
|
} from './numberFormat.js'
|
||||||
|
|
||||||
|
describe('numberFormat (device locale)', () => {
|
||||||
|
it('resolveDeviceLocale returns a non-empty BCP 47 tag', () => {
|
||||||
|
expect(resolveDeviceLocale().length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads decimal separator from Intl for de-DE and en-US', () => {
|
||||||
|
expect(getNumberFormatSymbols('de-DE').decimal).toBe(',')
|
||||||
|
expect(getNumberFormatSymbols('en-US').decimal).toBe('.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats decimals per locale without grouping', () => {
|
||||||
|
expect(formatAppDecimal(12.5, { maximumFractionDigits: 1, locale: 'de-DE' })).toBe('12,5')
|
||||||
|
expect(formatAppDecimal(12.5, { maximumFractionDigits: 1, locale: 'en-US' })).toBe('12.5')
|
||||||
|
expect(formatAppDecimal(1234.5, { maximumFractionDigits: 1, locale: 'de-DE' })).toBe('1234,5')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses device-locale decimals and tolerates the other separator', () => {
|
||||||
|
expect(parseAppDecimal('12,5', 'de-DE')).toBe(12.5)
|
||||||
|
expect(parseAppDecimal('12.5', 'en-US')).toBe(12.5)
|
||||||
|
expect(parseAppDecimal('12,5', 'en-US')).toBe(12.5)
|
||||||
|
expect(parseAppDecimal('1.234,5', 'de-DE')).toBe(1234.5)
|
||||||
|
expect(parseAppDecimal('', 'de-DE')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats coordinates for form display', () => {
|
||||||
|
expect(formatAppCoordinate(59.912345, 'de-DE')).toBe('59,912345')
|
||||||
|
expect(formatTankLiters(12.5)).toBe(formatAppDecimal(12.5, { minimumFractionDigits: 1, maximumFractionDigits: 1 }))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats GPS accuracy with coarse step from 100 m', () => {
|
||||||
|
expect(formatGpsAccuracyMeters(12.4)).toBe(formatAppDecimal(12, { maximumFractionDigits: 0 }))
|
||||||
|
expect(formatGpsAccuracyMeters(105)).toBe(formatAppDecimal(110, { maximumFractionDigits: 0 }))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
/**
|
||||||
|
* Number formatting and parsing follow the device (browser) locale from Intl,
|
||||||
|
* not the app UI language — e.g. de-DE phone with English UI still uses comma decimals.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function resolveDeviceLocale(): string {
|
||||||
|
try {
|
||||||
|
const locale = new Intl.NumberFormat().resolvedOptions().locale
|
||||||
|
if (locale) return locale
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
if (typeof navigator !== 'undefined' && navigator.language) {
|
||||||
|
return navigator.language
|
||||||
|
}
|
||||||
|
return 'en-GB'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NumberSymbols {
|
||||||
|
decimal: string
|
||||||
|
group: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const symbolCache = new Map<string, NumberSymbols>()
|
||||||
|
|
||||||
|
export function getNumberFormatSymbols(locale = resolveDeviceLocale()): NumberSymbols {
|
||||||
|
const cached = symbolCache.get(locale)
|
||||||
|
if (cached) return cached
|
||||||
|
const parts = new Intl.NumberFormat(locale).formatToParts(1234567.89)
|
||||||
|
const symbols: NumberSymbols = {
|
||||||
|
decimal: parts.find((p) => p.type === 'decimal')?.value ?? '.',
|
||||||
|
group: parts.find((p) => p.type === 'group')?.value ?? ''
|
||||||
|
}
|
||||||
|
symbolCache.set(locale, symbols)
|
||||||
|
return symbols
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormatAppDecimalOptions {
|
||||||
|
minimumFractionDigits?: number
|
||||||
|
maximumFractionDigits?: number
|
||||||
|
locale?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** User-visible decimal without thousands grouping. */
|
||||||
|
export function formatAppDecimal(value: number, options: FormatAppDecimalOptions = {}): string {
|
||||||
|
if (!Number.isFinite(value)) return ''
|
||||||
|
const locale = options.locale ?? resolveDeviceLocale()
|
||||||
|
const min = options.minimumFractionDigits ?? 0
|
||||||
|
const max = options.maximumFractionDigits ?? min
|
||||||
|
return new Intl.NumberFormat(locale, {
|
||||||
|
minimumFractionDigits: min,
|
||||||
|
maximumFractionDigits: max,
|
||||||
|
useGrouping: false
|
||||||
|
}).format(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a decimal typed by the user for the device locale.
|
||||||
|
* Also accepts the other common separator for simple values (e.g. 12,5 on en-US).
|
||||||
|
*/
|
||||||
|
export function parseAppDecimal(input: string, locale = resolveDeviceLocale()): number | null {
|
||||||
|
const trimmed = input.trim()
|
||||||
|
if (!trimmed) return null
|
||||||
|
|
||||||
|
const { decimal, group } = getNumberFormatSymbols(locale)
|
||||||
|
const simpleComma = /^-?\d+,\d+$/.test(trimmed)
|
||||||
|
const simpleDot = /^-?\d+\.\d+$/.test(trimmed)
|
||||||
|
|
||||||
|
// Values without grouping: accept locale decimal and the other common separator.
|
||||||
|
if (simpleComma && decimal === ',') {
|
||||||
|
return Number(trimmed.replace(',', '.'))
|
||||||
|
}
|
||||||
|
if (simpleDot && decimal === '.') {
|
||||||
|
return Number(trimmed)
|
||||||
|
}
|
||||||
|
if (simpleComma && decimal === '.') {
|
||||||
|
return Number(trimmed.replace(',', '.'))
|
||||||
|
}
|
||||||
|
if (simpleDot && decimal === ',') {
|
||||||
|
return Number(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized = trimmed
|
||||||
|
if (group) {
|
||||||
|
normalized = normalized.split(group).join('')
|
||||||
|
}
|
||||||
|
if (decimal !== '.') {
|
||||||
|
normalized = normalized.replace(decimal, '.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const n = Number(normalized)
|
||||||
|
return Number.isFinite(n) ? n : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAppDecimalOrZero(input: string, locale?: string): number {
|
||||||
|
return parseAppDecimal(input, locale) ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Canonical storage/API coordinate string (always dot, 6 decimals). */
|
||||||
|
export function formatCanonicalCoordinate(value: number): string {
|
||||||
|
return value.toFixed(6)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Coordinate string for form fields (device decimal separator). */
|
||||||
|
export function formatAppCoordinate(value: number, locale?: string): string {
|
||||||
|
return formatAppDecimal(value, { minimumFractionDigits: 6, maximumFractionDigits: 6, locale })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatNm(value: number): string {
|
||||||
|
return formatAppDecimal(value, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatLiters(value: number): string {
|
||||||
|
return Number.isInteger(value)
|
||||||
|
? formatAppDecimal(value, { maximumFractionDigits: 0 })
|
||||||
|
: formatAppDecimal(value, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatHours(value: number): string {
|
||||||
|
return formatLiters(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTankLiters(liters: number): string {
|
||||||
|
if (!Number.isFinite(liters) || liters <= 0) return formatAppDecimal(0, { maximumFractionDigits: 0 })
|
||||||
|
return formatLiters(liters)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFuelPerMotorHour(value: number | null | undefined): string {
|
||||||
|
if (value == null) return '—'
|
||||||
|
return Number.isInteger(value)
|
||||||
|
? formatAppDecimal(value, { maximumFractionDigits: 0 })
|
||||||
|
: formatAppDecimal(value, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GPS accuracy for i18n (±{{accuracy}} m): 1 m below 100 m, 10 m from 100 m upward. */
|
||||||
|
export function formatGpsAccuracyMeters(accuracyM: number): string {
|
||||||
|
const rounded = accuracyM < 100 ? Math.round(accuracyM) : Math.round(accuracyM / 10) * 10
|
||||||
|
return formatAppDecimal(rounded, { maximumFractionDigits: 0 })
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { degreesToCardinal } from './courseAngle.js'
|
import { degreesToCardinal } from './courseAngle.js'
|
||||||
|
import { formatAppDecimal } from './numberFormat.js'
|
||||||
import { formatVisibilityMeters } from './weatherMetrics.js'
|
import { formatVisibilityMeters } from './weatherMetrics.js'
|
||||||
|
|
||||||
/** @deprecated Use formatVisibilityMeters */
|
/** @deprecated Use formatVisibilityMeters */
|
||||||
@@ -33,7 +34,7 @@ export function mpsToBeaufort(mps: number): number {
|
|||||||
|
|
||||||
export function formatWindStrengthBeaufort(mps: number): string {
|
export function formatWindStrengthBeaufort(mps: number): string {
|
||||||
const bft = mpsToBeaufort(mps)
|
const bft = mpsToBeaufort(mps)
|
||||||
return `${bft} Bft (${mps.toFixed(1)} m/s)`
|
return `${bft} Bft (${formatAppDecimal(mps, { minimumFractionDigits: 1, maximumFractionDigits: 1 })} m/s)`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseOwmCurrentWeather(data: Record<string, unknown>): ParsedOwmCurrent {
|
export function parseOwmCurrentWeather(data: Record<string, unknown>): ParsedOwmCurrent {
|
||||||
@@ -49,7 +50,7 @@ export function parseOwmCurrentWeather(data: Record<string, unknown>): ParsedOwm
|
|||||||
|
|
||||||
let tempC: string | null = null
|
let tempC: string | null = null
|
||||||
if (main?.temp != null && Number.isFinite(main.temp)) {
|
if (main?.temp != null && Number.isFinite(main.temp)) {
|
||||||
tempC = Number(main.temp).toFixed(1)
|
tempC = formatAppDecimal(main.temp, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
||||||
}
|
}
|
||||||
|
|
||||||
let precipText: string | null = null
|
let precipText: string | null = null
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { formatTankLiters } from './logEntryTankLevels.js'
|
import { formatTankLiters, parseAppDecimal } from './numberFormat.js'
|
||||||
|
|
||||||
export interface VesselTankCapacities {
|
export interface VesselTankCapacities {
|
||||||
freshwaterCapacityL?: number
|
freshwaterCapacityL?: number
|
||||||
@@ -7,10 +7,10 @@ export interface VesselTankCapacities {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function parseOptionalTankLiters(input: string): number | undefined {
|
export function parseOptionalTankLiters(input: string): number | undefined {
|
||||||
const trimmed = input.trim().replace(',', '.')
|
const trimmed = input.trim()
|
||||||
if (!trimmed) return undefined
|
if (!trimmed) return undefined
|
||||||
const parsed = Number(trimmed)
|
const parsed = parseAppDecimal(trimmed)
|
||||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
if (parsed == null || parsed < 0) {
|
||||||
throw new Error('invalid_tank_liters')
|
throw new Error('invalid_tank_liters')
|
||||||
}
|
}
|
||||||
return parsed
|
return parsed
|
||||||
@@ -24,10 +24,10 @@ function capacityFromStored(value: unknown): number | undefined {
|
|||||||
if (value == null || value === '') return undefined
|
if (value == null || value === '') return undefined
|
||||||
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) return value
|
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) return value
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
const trimmed = value.trim().replace(',', '.')
|
const trimmed = value.trim()
|
||||||
if (!trimmed) return undefined
|
if (!trimmed) return undefined
|
||||||
const parsed = Number(trimmed)
|
const parsed = parseAppDecimal(trimmed)
|
||||||
if (Number.isFinite(parsed) && parsed >= 0) return parsed
|
if (parsed != null && parsed >= 0) return parsed
|
||||||
}
|
}
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user