Compare commits
111 Commits
v0.1.0.110
...
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 |
@@ -15,6 +15,11 @@ 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
|
||||||
@@ -29,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
|
||||||
|
|
||||||
@@ -36,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=
|
||||||
@@ -47,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
-51
@@ -1,51 +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_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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
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 */
|
||||||
|
})
|
||||||
|
})()
|
||||||
+757
-52
File diff suppressed because it is too large
Load Diff
+79
-4
@@ -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(),
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -597,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>
|
||||||
)
|
)
|
||||||
@@ -647,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
|
||||||
|
</span>
|
||||||
|
<span className="app-version-footer__sep" aria-hidden="true">
|
||||||
|
·
|
||||||
|
</span>
|
||||||
<a
|
<a
|
||||||
href="mailto:elpatron+kd@mailbox.org"
|
className="knorrlabs-footer-badge"
|
||||||
|
href="https://dashy.elpatron.me/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
onClick={() => trackPlausibleEvent(PlausibleEvents.FOOTER_LINK_CLICKED)}
|
onClick={() => trackPlausibleEvent(PlausibleEvents.FOOTER_LINK_CLICKED)}
|
||||||
>
|
>
|
||||||
Markus F.J. Busche
|
<Compass size={14} aria-hidden="true" />
|
||||||
|
<span>KnorrLabs</span>
|
||||||
</a>
|
</a>
|
||||||
|
<span className="app-version-footer__sep" aria-hidden="true">
|
||||||
|
·
|
||||||
</span>
|
</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>
|
||||||
|
|||||||
@@ -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,11 +91,33 @@ 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
|
||||||
|
className="form-header accordion-header"
|
||||||
|
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">
|
||||||
<Users size={22} className="form-icon" />
|
<Users size={22} className="form-icon" />
|
||||||
<h3>{t('entry_crew.title')}</h3>
|
<h3>{t('entry_crew.title')}</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="help-text mb-3">{t('entry_crew.subtitle')}</p>
|
{collapsed ? (
|
||||||
|
<ChevronDown size={20} className="accordion-chevron" />
|
||||||
|
) : (
|
||||||
|
<ChevronUp size={20} className="accordion-chevron" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!collapsed && (
|
||||||
|
<>
|
||||||
|
<p className="help-text mb-3" style={{ marginTop: '16px' }}>{t('entry_crew.subtitle')}</p>
|
||||||
|
|
||||||
<div className="input-group mb-3">
|
<div className="input-group mb-3">
|
||||||
<label>{t('entry_crew.day_skipper')}</label>
|
<label>{t('entry_crew.day_skipper')}</label>
|
||||||
@@ -138,6 +161,8 @@ export default function EntryCrewSection({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +1,139 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Mic, Loader2 } from 'lucide-react'
|
||||||
import type { LogEventPayload } from '../utils/logEntryPayload.js'
|
import type { LogEventPayload } from '../utils/logEntryPayload.js'
|
||||||
import { parseLiveVoiceRemark } from '../utils/liveEventCodes.js'
|
import { parseLiveVoiceRemark } from '../utils/liveEventCodes.js'
|
||||||
import { formatEventSummary } from '../utils/formatEventSummary.js'
|
import { formatEventSummary } from '../utils/formatEventSummary.js'
|
||||||
import VoiceMemoPlayer, { type PreloadedVoiceMemo } from './VoiceMemoPlayer.tsx'
|
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 {
|
interface EventRemarksCellProps {
|
||||||
event: LogEventPayload
|
event: LogEventPayload
|
||||||
logbookId: string
|
logbookId: string
|
||||||
voiceMemoLookup?: Map<string, PreloadedVoiceMemo>
|
voiceMemoLookup?: Map<string, PreloadedVoiceMemo>
|
||||||
|
readOnly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EventRemarksCell({
|
export default function EventRemarksCell({
|
||||||
event,
|
event,
|
||||||
logbookId,
|
logbookId,
|
||||||
voiceMemoLookup
|
voiceMemoLookup,
|
||||||
|
readOnly = false
|
||||||
}: EventRemarksCellProps) {
|
}: EventRemarksCellProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const { showAlert } = useDialog()
|
||||||
const voiceId = parseLiveVoiceRemark(event.remarks.trim())
|
const voiceId = parseLiveVoiceRemark(event.remarks.trim())
|
||||||
const preloaded = voiceId ? voiceMemoLookup?.get(voiceId) : undefined
|
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)
|
let summary = formatEventSummary(event, t)
|
||||||
if (voiceId && preloaded?.caption) {
|
if (voiceId && preloaded?.caption) {
|
||||||
summary = t('logs.live_voice_entry', { caption: preloaded.caption })
|
summary = t('logs.live_voice_entry', { caption: preloaded.caption })
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="event-remarks-cell">
|
<div className={`event-remarks-cell${voiceId ? ' event-remarks-cell--voice' : ''}`}>
|
||||||
<span>{summary}</span>
|
<span>{summary}</span>
|
||||||
{voiceId && (
|
{voiceId && (
|
||||||
|
<div style={{ display: 'inline-flex', alignItems: 'center', flexWrap: 'wrap', gap: '8px', marginTop: '4px' }}>
|
||||||
<VoiceMemoPlayer
|
<VoiceMemoPlayer
|
||||||
audioId={voiceId}
|
audioId={voiceId}
|
||||||
logbookId={logbookId}
|
logbookId={logbookId}
|
||||||
preloaded={preloaded}
|
preloaded={preloaded}
|
||||||
compact
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -22,19 +22,20 @@ import {
|
|||||||
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,
|
||||||
@@ -42,7 +43,6 @@ import {
|
|||||||
liveFuelRemark,
|
liveFuelRemark,
|
||||||
livePhotoRemark,
|
livePhotoRemark,
|
||||||
liveVoiceRemark,
|
liveVoiceRemark,
|
||||||
parseLiveVoiceRemark,
|
|
||||||
livePrecipRemark,
|
livePrecipRemark,
|
||||||
liveSailsRemark,
|
liveSailsRemark,
|
||||||
liveSogRemark,
|
liveSogRemark,
|
||||||
@@ -50,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 {
|
||||||
@@ -66,9 +76,10 @@ 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 LiveVoiceCapture from './LiveVoiceCapture.tsx'
|
||||||
import VoiceMemoPlayer from './VoiceMemoPlayer.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 { saveEntryVoiceMemo, deleteEntryVoiceMemo } from '../services/voiceAttachments.js'
|
||||||
import { blobToCompressedJpegDataUrl } from '../utils/imageCompress.js'
|
import { blobToCompressedJpegDataUrl } from '../utils/imageCompress.js'
|
||||||
@@ -96,7 +107,7 @@ type LiveModal =
|
|||||||
| 'water'
|
| 'water'
|
||||||
| 'sog'
|
| 'sog'
|
||||||
| 'stw'
|
| 'stw'
|
||||||
| 'fix'
|
| 'position'
|
||||||
| 'photo'
|
| 'photo'
|
||||||
| 'voice'
|
| 'voice'
|
||||||
|
|
||||||
@@ -142,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)
|
||||||
@@ -167,10 +206,15 @@ export default function LiveLogView({
|
|||||||
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 [voiceCaption, setVoiceCaption] = useState('')
|
const [voiceCaption, setVoiceCaption] = useState('')
|
||||||
@@ -190,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']
|
||||||
@@ -202,8 +291,8 @@ 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 voiceMemoLookup = useEntryVoiceMemos(logbookId, entryId)
|
||||||
@@ -213,6 +302,8 @@ export default function LiveLogView({
|
|||||||
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) => {
|
||||||
@@ -310,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])
|
||||||
@@ -358,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
|
||||||
}
|
}
|
||||||
@@ -377,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>,
|
||||||
@@ -453,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({
|
||||||
@@ -470,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({
|
||||||
@@ -497,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')
|
||||||
@@ -522,9 +671,9 @@ 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 = () => {
|
||||||
@@ -534,17 +683,17 @@ export default function LiveLogView({
|
|||||||
return
|
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
|
||||||
@@ -563,14 +712,28 @@ export default function LiveLogView({
|
|||||||
{ analyticsSource: 'live_log' }
|
{ analyticsSource: 'live_log' }
|
||||||
)
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof WeatherApiError && err.code === 'OFFLINE') {
|
if (err instanceof WeatherApiError) {
|
||||||
|
if (err.code === 'OFFLINE') {
|
||||||
void showAlert(t('logs.weather_offline'), t('logs.live_weather_owm_btn'))
|
void showAlert(t('logs.weather_offline'), t('logs.live_weather_owm_btn'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (err instanceof WeatherApiError && err.code === 'NO_KEY') {
|
if (err.code === 'NO_KEY') {
|
||||||
void showAlert(t('settings.no_key'), t('logs.live_weather_owm_btn'))
|
void showAlert(t('settings.no_key'), t('logs.live_weather_owm_btn'))
|
||||||
return
|
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'))
|
||||||
return
|
return
|
||||||
@@ -672,13 +835,50 @@ export default function LiveLogView({
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const audioDataUrl = await blobToAudioDataUrl(blob)
|
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({
|
const voiceId = await saveEntryVoiceMemo({
|
||||||
logbookId,
|
logbookId,
|
||||||
entryId,
|
entryId,
|
||||||
audioDataUrl,
|
audioDataUrl,
|
||||||
mimeType,
|
mimeType,
|
||||||
durationSec,
|
durationSec,
|
||||||
caption,
|
caption: finalCaption,
|
||||||
|
transcribed,
|
||||||
analyticsContext: 'live_log'
|
analyticsContext: 'live_log'
|
||||||
})
|
})
|
||||||
await appendQuickEvent(logbookId, entryId, {
|
await appendQuickEvent(logbookId, entryId, {
|
||||||
@@ -690,6 +890,23 @@ export default function LiveLogView({
|
|||||||
setVoiceCaption('')
|
setVoiceCaption('')
|
||||||
showUndo('voice')
|
showUndo('voice')
|
||||||
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: '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) {
|
} catch (err: unknown) {
|
||||||
console.error('Live log voice save failed:', err)
|
console.error('Live log voice save failed:', err)
|
||||||
const msg = err instanceof Error && err.message === 'VOICE_MEMO_TOO_LARGE'
|
const msg = err instanceof Error && err.message === 'VOICE_MEMO_TOO_LARGE'
|
||||||
@@ -843,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
|
||||||
@@ -945,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')}
|
||||||
@@ -1036,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} />
|
||||||
@@ -1061,25 +1278,21 @@ export default function LiveLogView({
|
|||||||
) : (
|
) : (
|
||||||
<ol className="live-log-stream">
|
<ol className="live-log-stream">
|
||||||
{events.map((event, index) => {
|
{events.map((event, index) => {
|
||||||
const voiceId = parseLiveVoiceRemark(event.remarks.trim())
|
|
||||||
const voicePreloaded = voiceId ? voiceMemoLookup.get(voiceId) : undefined
|
|
||||||
let summary = formatEventSummary(event, t)
|
|
||||||
if (voiceId && voicePreloaded?.caption) {
|
|
||||||
summary = t('logs.live_voice_entry', { caption: voicePreloaded.caption })
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<li key={`${event.time}-${index}`} className="live-log-entry">
|
<li key={`${event.time}-${index}`} className="live-log-entry">
|
||||||
<time className="live-log-time">{event.time}</time>
|
<time className="live-log-time">{event.time}</time>
|
||||||
<div className="live-log-summary-block">
|
<CreatorAvatar
|
||||||
<span className="live-log-summary">{summary}</span>
|
creatorId={event.creatorId}
|
||||||
{voiceId && (
|
crewSnapshotsById={crewSnapshotsById}
|
||||||
<VoiceMemoPlayer
|
size={24}
|
||||||
audioId={voiceId}
|
/>
|
||||||
logbookId={logbookId}
|
<div className="live-log-summary-block">
|
||||||
preloaded={voicePreloaded}
|
<EventRemarksCell
|
||||||
compact
|
event={event}
|
||||||
|
logbookId={logbookId}
|
||||||
|
voiceMemoLookup={voiceMemoLookup}
|
||||||
|
readOnly={false}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
@@ -1145,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"
|
||||||
@@ -1161,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>
|
||||||
@@ -1237,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>
|
||||||
@@ -1271,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>
|
||||||
@@ -1293,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>
|
||||||
@@ -1338,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>
|
||||||
|
|||||||
@@ -43,6 +43,53 @@ export default function LiveVoiceCapture({
|
|||||||
const [previewMime, setPreviewMime] = useState('audio/webm')
|
const [previewMime, setPreviewMime] = useState('audio/webm')
|
||||||
const [previewDurationSec, setPreviewDurationSec] = useState(0)
|
const [previewDurationSec, setPreviewDurationSec] = useState(0)
|
||||||
const [saving, setSaving] = useState(false)
|
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(() => {
|
const stopStream = useCallback(() => {
|
||||||
for (const track of streamRef.current?.getTracks() ?? []) {
|
for (const track of streamRef.current?.getTracks() ?? []) {
|
||||||
@@ -110,24 +157,51 @@ export default function LiveVoiceCapture({
|
|||||||
if (!recorder || recorder.state !== 'recording') return
|
if (!recorder || recorder.state !== 'recording') return
|
||||||
recorder.stop()
|
recorder.stop()
|
||||||
clearTimer()
|
clearTimer()
|
||||||
stopStream()
|
}, [clearTimer])
|
||||||
}, [clearTimer, stopStream])
|
|
||||||
|
|
||||||
const startRecording = async () => {
|
const startRecording = async () => {
|
||||||
setMicError(null)
|
setMicError(null)
|
||||||
chunksRef.current = []
|
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 {
|
try {
|
||||||
|
log('Requesting getUserMedia audio stream...')
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||||
streamRef.current = stream
|
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()
|
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
|
const recorder = mimeType
|
||||||
? new MediaRecorder(stream, { mimeType })
|
? new MediaRecorder(stream, { mimeType })
|
||||||
: new MediaRecorder(stream)
|
: new MediaRecorder(stream)
|
||||||
mediaRecorderRef.current = recorder
|
mediaRecorderRef.current = recorder
|
||||||
const resolvedMime = recorder.mimeType || mimeType || 'audio/webm'
|
const resolvedMime = recorder.mimeType || mimeType || 'audio/webm'
|
||||||
|
log('MediaRecorder created. Resolved mime=' + resolvedMime)
|
||||||
|
|
||||||
recorder.ondataavailable = (ev) => {
|
recorder.ondataavailable = (ev) => {
|
||||||
if (ev.data.size > 0) chunksRef.current.push(ev.data)
|
log(`ondataavailable event: data size=${ev.data?.size} bytes`)
|
||||||
|
if (ev.data && ev.data.size > 0) {
|
||||||
|
chunksRef.current.push(ev.data)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
recorder.onstop = () => {
|
recorder.onstop = () => {
|
||||||
@@ -135,44 +209,67 @@ export default function LiveVoiceCapture({
|
|||||||
VOICE_MEMO_MAX_DURATION_SEC,
|
VOICE_MEMO_MAX_DURATION_SEC,
|
||||||
Math.max(1, Math.round((Date.now() - startedAtRef.current) / 1000))
|
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 })
|
const blob = new Blob(chunksRef.current, { type: resolvedMime })
|
||||||
chunksRef.current = []
|
chunksRef.current = []
|
||||||
|
stopStream()
|
||||||
|
log(`Blob finalized: size=${blob.size} bytes, type=${blob.type}`)
|
||||||
try {
|
try {
|
||||||
assertVoiceMemoBlobSize(blob)
|
assertVoiceMemoBlobSize(blob)
|
||||||
|
log('Blob size assertion passed. Calling finishRecording...')
|
||||||
finishRecording(blob, resolvedMime, durationSec)
|
finishRecording(blob, resolvedMime, durationSec)
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
log('Blob size assertion failed (too large)')
|
||||||
setMicError(t('logs.live_voice_too_large'))
|
setMicError(t('logs.live_voice_too_large'))
|
||||||
setPhase('idle')
|
setPhase('idle')
|
||||||
}
|
}
|
||||||
|
}, 50)
|
||||||
}
|
}
|
||||||
|
|
||||||
recorder.onerror = () => {
|
recorder.onerror = (ev) => {
|
||||||
|
log('MediaRecorder onerror triggered: ' + JSON.stringify(ev))
|
||||||
setMicError(t('logs.live_voice_record_failed'))
|
setMicError(t('logs.live_voice_record_failed'))
|
||||||
resetAll()
|
resetAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
startedAtRef.current = Date.now()
|
startedAtRef.current = Date.now()
|
||||||
recorder.start(200)
|
log('Calling recorder.start()...')
|
||||||
|
recorder.start()
|
||||||
|
log('recorder.start() called. State=' + recorder.state)
|
||||||
setPhase('recording')
|
setPhase('recording')
|
||||||
setElapsedSec(0)
|
setElapsedSec(0)
|
||||||
timerRef.current = window.setInterval(() => {
|
timerRef.current = window.setInterval(() => {
|
||||||
const sec = Math.floor((Date.now() - startedAtRef.current) / 1000)
|
const sec = Math.floor((Date.now() - startedAtRef.current) / 1000)
|
||||||
setElapsedSec(sec)
|
setElapsedSec(sec)
|
||||||
if (sec >= VOICE_MEMO_MAX_DURATION_SEC) {
|
if (sec >= VOICE_MEMO_MAX_DURATION_SEC) {
|
||||||
|
log('Max duration reached. Stopping recording...')
|
||||||
stopRecording()
|
stopRecording()
|
||||||
}
|
}
|
||||||
}, 250)
|
}, 250)
|
||||||
} catch {
|
} 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'))
|
setMicError(t('logs.live_voice_mic_denied'))
|
||||||
stopStream()
|
stopStream()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!previewBlob || saving || busy) return
|
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)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
onSave(previewBlob, previewMime, previewDurationSec)
|
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 {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
@@ -195,7 +292,7 @@ export default function LiveVoiceCapture({
|
|||||||
className="btn-icon"
|
className="btn-icon"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
disabled={busy || saving || phase === 'recording'}
|
disabled={busy || saving || phase === 'recording'}
|
||||||
aria-label={t('logs.confirm_no')}
|
aria-label={t('logs.live_cancel')}
|
||||||
>
|
>
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
</button>
|
</button>
|
||||||
@@ -237,7 +334,7 @@ export default function LiveVoiceCapture({
|
|||||||
|
|
||||||
{phase === 'preview' && previewUrl && (
|
{phase === 'preview' && previewUrl && (
|
||||||
<>
|
<>
|
||||||
<audio className="voice-memo-player" controls src={previewUrl} preload="auto" />
|
<audio ref={previewAudioRef} className="voice-memo-player" controls src={previewUrl} preload="auto" />
|
||||||
{onCaptionChange && (
|
{onCaptionChange && (
|
||||||
<label className="live-voice-caption-field">
|
<label className="live-voice-caption-field">
|
||||||
<span>{t('logs.live_voice_caption_label')}</span>
|
<span>{t('logs.live_voice_caption_label')}</span>
|
||||||
@@ -274,6 +371,8 @@ export default function LiveVoiceCapture({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ 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, tryDecryptEntryPayload } 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'
|
||||||
@@ -123,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[] = []
|
||||||
@@ -300,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(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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,19 +152,46 @@ 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
|
||||||
|
className="form-header accordion-header"
|
||||||
|
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" />
|
<Camera size={20} className="form-icon" />
|
||||||
<h3>{t('logs.photos_title')}</h3>
|
<h3>{t('logs.photos_title')}</h3>
|
||||||
</div>
|
</div>
|
||||||
|
{collapsed ? (
|
||||||
|
<ChevronDown size={20} className="accordion-chevron" />
|
||||||
|
) : (
|
||||||
|
<ChevronUp size={20} className="accordion-chevron" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!collapsed && (
|
||||||
|
<div style={{ marginTop: '16px' }}>
|
||||||
{error && <div className="auth-error mb-4">{error}</div>}
|
{error && <div className="auth-error mb-4">{error}</div>}
|
||||||
|
|
||||||
{/* Upload area */}
|
{/* Upload area */}
|
||||||
@@ -159,10 +219,51 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
|
|||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
|
ref={cameraInputRef}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{hasCamera ? (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn primary"
|
className="btn primary"
|
||||||
onClick={triggerSelect}
|
onClick={triggerCameraSelect}
|
||||||
|
disabled={uploading}
|
||||||
|
style={{ width: 'auto', padding: '12px 20px', display: 'flex', gap: '8px', alignItems: 'center' }}
|
||||||
|
>
|
||||||
|
{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
|
||||||
|
type="button"
|
||||||
|
className="btn primary"
|
||||||
|
onClick={triggerGallerySelect}
|
||||||
disabled={uploading}
|
disabled={uploading}
|
||||||
style={{ width: 'auto', padding: '12px 24px', display: 'flex', gap: '8px', alignItems: 'center' }}
|
style={{ width: 'auto', padding: '12px 24px', display: 'flex', gap: '8px', alignItems: 'center' }}
|
||||||
>
|
>
|
||||||
@@ -173,6 +274,7 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
|
|||||||
)}
|
)}
|
||||||
{uploading ? t('logs.photo_processing') : t('logs.photo_btn')}
|
{uploading ? t('logs.photo_processing') : t('logs.photo_btn')}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -183,14 +285,22 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
|
|||||||
) : (
|
) : (
|
||||||
<div className="photo-attachments-grid">
|
<div className="photo-attachments-grid">
|
||||||
{decryptedPhotos.map((photo) => (
|
{decryptedPhotos.map((photo) => (
|
||||||
<div key={photo.payloadId} className="photo-card glass">
|
<div
|
||||||
|
key={photo.payloadId}
|
||||||
|
className="photo-card glass"
|
||||||
|
onClick={() => setMaximizedPhoto(photo)}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
<div className="photo-container">
|
<div className="photo-container">
|
||||||
<img src={photo.image} alt={photo.caption || 'Attachment'} loading="lazy" />
|
<img src={photo.image} alt={photo.caption || 'Attachment'} loading="lazy" />
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="photo-btn-delete"
|
className="photo-btn-delete"
|
||||||
onClick={() => handleDelete(photo.payloadId)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleDelete(photo.payloadId)
|
||||||
|
}}
|
||||||
title="Remove photo"
|
title="Remove photo"
|
||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
@@ -207,5 +317,36 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
|
|||||||
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, useRef } from 'react'
|
||||||
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'
|
||||||
@@ -11,6 +11,7 @@ export interface PreloadedVoiceMemo {
|
|||||||
mimeType?: string
|
mimeType?: string
|
||||||
durationSec?: number
|
durationSec?: number
|
||||||
caption?: string
|
caption?: string
|
||||||
|
transcribed?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VoiceMemoPlayerProps {
|
interface VoiceMemoPlayerProps {
|
||||||
@@ -30,6 +31,38 @@ export default function VoiceMemoPlayer({
|
|||||||
const [src, setSrc] = useState<string | null>(preloaded?.audio ?? null)
|
const [src, setSrc] = useState<string | null>(preloaded?.audio ?? null)
|
||||||
const [error, setError] = useState(false)
|
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(() => {
|
useEffect(() => {
|
||||||
if (preloaded?.audio) {
|
if (preloaded?.audio) {
|
||||||
setSrc(preloaded.audio)
|
setSrc(preloaded.audio)
|
||||||
@@ -69,12 +102,13 @@ export default function VoiceMemoPlayer({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const playerClass = compact
|
||||||
|
? 'voice-memo-player voice-memo-player--compact'
|
||||||
|
: 'voice-memo-player'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<audio
|
<div className="voice-memo-player-shell">
|
||||||
className={compact ? 'voice-memo-player voice-memo-player--compact' : 'voice-memo-player'}
|
<audio ref={audioRef} className={playerClass} controls preload="metadata" src={src} />
|
||||||
controls
|
</div>
|
||||||
preload="none"
|
|
||||||
src={src}
|
|
||||||
/>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ export function useEntryVoiceMemos(
|
|||||||
audio: String(decrypted.audio),
|
audio: String(decrypted.audio),
|
||||||
mimeType: decrypted.mimeType ? String(decrypted.mimeType) : undefined,
|
mimeType: decrypted.mimeType ? String(decrypted.mimeType) : undefined,
|
||||||
durationSec: typeof decrypted.durationSec === 'number' ? decrypted.durationSec : undefined,
|
durationSec: typeof decrypted.durationSec === 'number' ? decrypted.durationSec : undefined,
|
||||||
caption: decrypted.caption ? String(decrypted.caption) : ''
|
caption: decrypted.caption ? String(decrypted.caption) : '',
|
||||||
|
transcribed: decrypted.transcribed !== false
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
// skip corrupt memo
|
// skip corrupt memo
|
||||||
|
|||||||
@@ -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,6 +278,7 @@
|
|||||||
"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",
|
||||||
@@ -283,6 +300,9 @@
|
|||||||
"live_voice_entry_plain": "Stemmenotat",
|
"live_voice_entry_plain": "Stemmenotat",
|
||||||
"live_voice_caption_label": "Billedtekst (valgfrit)",
|
"live_voice_caption_label": "Billedtekst (valgfrit)",
|
||||||
"live_voice_caption_placeholder": "f.eks. radiokontakt med havnemester",
|
"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_undo_voice_hint": "Stemmenotat gemt",
|
||||||
"live_comment_btn": "Kommentar",
|
"live_comment_btn": "Kommentar",
|
||||||
"live_comment_placeholder": "Indtast tekst…",
|
"live_comment_placeholder": "Indtast tekst…",
|
||||||
@@ -293,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",
|
||||||
@@ -302,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",
|
||||||
@@ -316,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",
|
||||||
@@ -338,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",
|
||||||
@@ -375,6 +397,24 @@
|
|||||||
"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.",
|
"weather_offline": "OpenWeatherMap kræver internetforbindelse. Du er offline lige nu.",
|
||||||
"event_wind_pressure": "Lufttryk (hPa)",
|
"event_wind_pressure": "Lufttryk (hPa)",
|
||||||
@@ -402,10 +442,12 @@
|
|||||||
"ai_summary_error_rate_limited": "Maksimalt antal genereringer nået for denne rejsedag.",
|
"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_error_forbidden": "Kun skipperen må generere AI-resuméer.",
|
||||||
"ai_summary_offline": "AI-resumé kræver internetforbindelse. Du er offline lige nu.",
|
"ai_summary_offline": "AI-resumé kræver internetforbindelse. Du er offline lige nu.",
|
||||||
"photos_title": "Vedhæftede billeder (E2E-krypteret)",
|
"photos_title": "Vedhæftede billeder",
|
||||||
"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?",
|
||||||
@@ -480,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",
|
||||||
@@ -635,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",
|
||||||
@@ -756,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)",
|
||||||
|
|||||||
@@ -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,6 +278,7 @@
|
|||||||
"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",
|
||||||
@@ -283,18 +300,21 @@
|
|||||||
"live_voice_entry_plain": "Sprachnotiz",
|
"live_voice_entry_plain": "Sprachnotiz",
|
||||||
"live_voice_caption_label": "Beschriftung (optional)",
|
"live_voice_caption_label": "Beschriftung (optional)",
|
||||||
"live_voice_caption_placeholder": "z. B. Funkverkehr mit Hafenmeister",
|
"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_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",
|
||||||
@@ -302,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",
|
||||||
@@ -316,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",
|
||||||
@@ -338,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",
|
||||||
@@ -375,6 +397,24 @@
|
|||||||
"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.",
|
"weather_offline": "OpenWeatherMap erfordert eine Internetverbindung. Du bist derzeit offline.",
|
||||||
"event_wind_pressure": "Luftdruck (hPa)",
|
"event_wind_pressure": "Luftdruck (hPa)",
|
||||||
@@ -402,10 +442,12 @@
|
|||||||
"ai_summary_error_rate_limited": "Maximale Anzahl an Generierungen für diesen Reisetag erreicht.",
|
"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_error_forbidden": "Nur der Skipper darf KI-Zusammenfassungen generieren.",
|
||||||
"ai_summary_offline": "Die KI-Zusammenfassung erfordert eine Internetverbindung. Du bist derzeit offline.",
|
"ai_summary_offline": "Die KI-Zusammenfassung erfordert eine Internetverbindung. Du bist derzeit offline.",
|
||||||
"photos_title": "Foto-Anhänge (E2E-verschlüsselt)",
|
"photos_title": "Foto-Anhänge",
|
||||||
"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?",
|
||||||
@@ -470,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",
|
||||||
@@ -635,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",
|
||||||
@@ -756,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)",
|
||||||
|
|||||||
@@ -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,6 +278,7 @@
|
|||||||
"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",
|
||||||
@@ -283,18 +300,21 @@
|
|||||||
"live_voice_entry_plain": "Voice memo",
|
"live_voice_entry_plain": "Voice memo",
|
||||||
"live_voice_caption_label": "Caption (optional)",
|
"live_voice_caption_label": "Caption (optional)",
|
||||||
"live_voice_caption_placeholder": "e.g. radio call with harbour master",
|
"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_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",
|
||||||
@@ -302,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",
|
||||||
@@ -316,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",
|
||||||
@@ -338,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",
|
||||||
@@ -375,6 +397,24 @@
|
|||||||
"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.",
|
"weather_offline": "OpenWeatherMap requires an internet connection. You are currently offline.",
|
||||||
"event_wind_pressure": "Barometer (hPa)",
|
"event_wind_pressure": "Barometer (hPa)",
|
||||||
@@ -402,10 +442,12 @@
|
|||||||
"ai_summary_error_rate_limited": "Maximum number of generations reached for this travel day.",
|
"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_error_forbidden": "Only the skipper may generate AI summaries.",
|
||||||
"ai_summary_offline": "AI summary generation requires an internet connection. You are currently offline.",
|
"ai_summary_offline": "AI summary generation requires an internet connection. You are currently offline.",
|
||||||
"photos_title": "Photo Attachments (E2E Encrypted)",
|
"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?",
|
||||||
@@ -470,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",
|
||||||
@@ -635,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",
|
||||||
@@ -756,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)",
|
||||||
|
|||||||
@@ -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,6 +278,7 @@
|
|||||||
"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",
|
||||||
@@ -283,6 +300,9 @@
|
|||||||
"live_voice_entry_plain": "Talemelding",
|
"live_voice_entry_plain": "Talemelding",
|
||||||
"live_voice_caption_label": "Bildetekst (valgfritt)",
|
"live_voice_caption_label": "Bildetekst (valgfritt)",
|
||||||
"live_voice_caption_placeholder": "f.eks. radiokontakt med havnesjef",
|
"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_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…",
|
||||||
@@ -293,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",
|
||||||
@@ -302,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",
|
||||||
@@ -316,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",
|
||||||
@@ -338,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",
|
||||||
@@ -375,6 +397,24 @@
|
|||||||
"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.",
|
"weather_offline": "OpenWeatherMap krever internettforbindelse. Du er frakoblet.",
|
||||||
"event_wind_pressure": "Lufttrykk (hPa)",
|
"event_wind_pressure": "Lufttrykk (hPa)",
|
||||||
@@ -402,10 +442,12 @@
|
|||||||
"ai_summary_error_rate_limited": "Maksimalt antall genereringer nådd for denne reisedagen.",
|
"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_error_forbidden": "Kun skipperen kan generere AI-sammendrag.",
|
||||||
"ai_summary_offline": "AI-sammendrag krever internettforbindelse. Du er frakoblet.",
|
"ai_summary_offline": "AI-sammendrag krever internettforbindelse. Du er frakoblet.",
|
||||||
"photos_title": "Bildevedlegg (E2E-kryptert)",
|
"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?",
|
||||||
@@ -480,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",
|
||||||
@@ -635,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",
|
||||||
@@ -756,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)",
|
||||||
|
|||||||
@@ -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,6 +278,7 @@
|
|||||||
"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",
|
||||||
@@ -283,6 +300,9 @@
|
|||||||
"live_voice_entry_plain": "Röstanteckning",
|
"live_voice_entry_plain": "Röstanteckning",
|
||||||
"live_voice_caption_label": "Bildtext (valfritt)",
|
"live_voice_caption_label": "Bildtext (valfritt)",
|
||||||
"live_voice_caption_placeholder": "t.ex. radiokontakt med hamnmästare",
|
"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_undo_voice_hint": "Röstanteckning sparad",
|
||||||
"live_comment_btn": "Kommentar",
|
"live_comment_btn": "Kommentar",
|
||||||
"live_comment_placeholder": "Ange text…",
|
"live_comment_placeholder": "Ange text…",
|
||||||
@@ -293,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",
|
||||||
@@ -302,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",
|
||||||
@@ -316,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",
|
||||||
@@ -338,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",
|
||||||
@@ -375,6 +397,24 @@
|
|||||||
"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.",
|
"weather_offline": "OpenWeatherMap kräver internetanslutning. Du är offline.",
|
||||||
"event_wind_pressure": "Lufttryck (hPa)",
|
"event_wind_pressure": "Lufttryck (hPa)",
|
||||||
@@ -402,10 +442,12 @@
|
|||||||
"ai_summary_error_rate_limited": "Maximalt antal genereringar nått för denna resedag.",
|
"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_error_forbidden": "Endast skepparen får generera AI-sammanfattningar.",
|
||||||
"ai_summary_offline": "AI-sammanfattning kräver internetanslutning. Du är offline.",
|
"ai_summary_offline": "AI-sammanfattning kräver internetanslutning. Du är offline.",
|
||||||
"photos_title": "Fotobilagor (E2E-krypterade)",
|
"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?",
|
||||||
@@ -480,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",
|
||||||
@@ -635,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",
|
||||||
@@ -756,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)",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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,9 +41,8 @@ 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_UPLOADED: 'Voice Memo Uploaded',
|
||||||
LIVE_LOG_VOICE_UPLOADED: 'Live Log Voice 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',
|
AI_SUMMARY_GENERATED: 'AI Summary Generated',
|
||||||
PWA_BOOT_WATCHDOG_SOFT: 'PWA Boot Watchdog Soft',
|
PWA_BOOT_WATCHDOG_SOFT: 'PWA Boot Watchdog Soft',
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
|||||||
'Date', 'Day of Travel', 'Departure Port', 'Destination Port', 'AI Summary',
|
'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',
|
||||||
@@ -122,6 +122,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
|||||||
const greywaterLevel = entry.greywater?.level ?? '';
|
const greywaterLevel = entry.greywater?.level ?? '';
|
||||||
const aiSummary = entry.aiSummary ?? '';
|
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
|
||||||
@@ -129,7 +130,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
|||||||
dateVal, travelDay, dep, dest, aiSummary,
|
dateVal, travelDay, dep, dest, aiSummary,
|
||||||
signS, signC,
|
signS, signC,
|
||||||
trackDist, trackMax, trackAvg, motorH,
|
trackDist, trackMax, trackAvg, motorH,
|
||||||
'', '', '',
|
'', '', '', '',
|
||||||
'', '', '', '', '',
|
'', '', '', '', '',
|
||||||
'', '', '', '', '',
|
'', '', '', '', '',
|
||||||
'', '', '',
|
'', '', '',
|
||||||
@@ -142,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, aiSummary,
|
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 || '',
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -639,9 +640,10 @@ export function downloadBackupBlob(blob: Blob, filename: string): void {
|
|||||||
|
|
||||||
/** Human-readable size for UI warnings. */
|
/** Human-readable size for UI warnings. */
|
||||||
export function formatBackupBytes(bytes: number): string {
|
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) return `${bytes} B`
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
if (bytes < 1024 * 1024) return `${fmt(bytes / 1024)} KB`
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
return `${fmt(bytes / (1024 * 1024))} MB`
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BACKUP_SIZE_WARN_BYTES = 50_000_000
|
export const BACKUP_SIZE_WARN_BYTES = 50_000_000
|
||||||
|
|||||||
@@ -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(', ')}` : ''
|
||||||
|
|
||||||
if (entry.trackDistanceNm) {
|
|
||||||
doc.setFont('Helvetica', 'normal');
|
doc.setFont('Helvetica', 'normal');
|
||||||
|
if (entry.trackDistanceNm) {
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,7 +129,8 @@ 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
|
||||||
@@ -151,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> {
|
||||||
@@ -185,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,
|
||||||
@@ -227,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)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
const existing = await findTodayEntryId(id)
|
}
|
||||||
if (existing) return existing
|
return inflight
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { db } from './db.js'
|
import { db } from './db.js'
|
||||||
import { getActiveMasterKey } from './auth.js'
|
import { getActiveMasterKey } from './auth.js'
|
||||||
import { getLogbookKey } from './logbookKeys.js'
|
import { getLogbookKey } from './logbookKeys.js'
|
||||||
import { encryptJson } from './crypto.js'
|
import { encryptJson, decryptJson } from './crypto.js'
|
||||||
import { syncLogbook } from './sync.js'
|
import { syncLogbook } from './sync.js'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ export async function saveEntryVoiceMemo(options: {
|
|||||||
mimeType: string
|
mimeType: string
|
||||||
durationSec: number
|
durationSec: number
|
||||||
caption?: string
|
caption?: string
|
||||||
|
transcribed?: boolean
|
||||||
analyticsContext?: string
|
analyticsContext?: string
|
||||||
}): Promise<string> {
|
}): Promise<string> {
|
||||||
const {
|
const {
|
||||||
@@ -27,6 +28,7 @@ export async function saveEntryVoiceMemo(options: {
|
|||||||
mimeType,
|
mimeType,
|
||||||
durationSec,
|
durationSec,
|
||||||
caption = '',
|
caption = '',
|
||||||
|
transcribed = true,
|
||||||
analyticsContext = 'logbook'
|
analyticsContext = 'logbook'
|
||||||
} = options
|
} = options
|
||||||
const masterKey = await getEncryptionKey(logbookId)
|
const masterKey = await getEncryptionKey(logbookId)
|
||||||
@@ -35,7 +37,8 @@ export async function saveEntryVoiceMemo(options: {
|
|||||||
audio: audioDataUrl,
|
audio: audioDataUrl,
|
||||||
mimeType,
|
mimeType,
|
||||||
durationSec,
|
durationSec,
|
||||||
caption: caption.trim()
|
caption: caption.trim(),
|
||||||
|
transcribed: !!transcribed
|
||||||
}
|
}
|
||||||
|
|
||||||
const encrypted = await encryptJson(voicePayload, masterKey)
|
const encrypted = await encryptJson(voicePayload, masterKey)
|
||||||
@@ -66,9 +69,6 @@ export async function saveEntryVoiceMemo(options: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_UPLOADED, { context: analyticsContext })
|
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_UPLOADED, { context: analyticsContext })
|
||||||
if (analyticsContext === 'live_log') {
|
|
||||||
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_VOICE_UPLOADED)
|
|
||||||
}
|
|
||||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||||
return voiceId
|
return voiceId
|
||||||
}
|
}
|
||||||
@@ -101,3 +101,55 @@ export async function removeLastVoiceMemoForEntry(
|
|||||||
await deleteEntryVoiceMemo(logbookId, lastId)
|
await deleteEntryVoiceMemo(logbookId, lastId)
|
||||||
return 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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -69,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' | 'OFFLINE' | 'REQUEST_FAILED'
|
code: 'NO_KEY' | 'OFFLINE' | 'REQUEST_FAILED' | 'UNAUTHORIZED' | 'NOT_FOUND' | 'BAD_REQUEST'
|
||||||
|
|
||||||
constructor(message: string, code: 'NO_KEY' | 'OFFLINE' | '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
|
||||||
@@ -38,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()
|
||||||
@@ -65,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,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'
|
||||||
|
}
|
||||||
@@ -21,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`,
|
||||||
@@ -85,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', () => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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,
|
||||||
@@ -58,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 {
|
||||||
@@ -148,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()
|
||||||
@@ -182,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { TrackWaypoint } from '../services/trackUpload.js'
|
import type { TrackWaypoint } from '../services/trackUpload.js'
|
||||||
|
import { formatAppDecimal } from './numberFormat.js'
|
||||||
|
|
||||||
const NM_IN_METERS = 1852
|
const NM_IN_METERS = 1852
|
||||||
const MAX_PLAUSIBLE_KNOTS = 50
|
const MAX_PLAUSIBLE_KNOTS = 50
|
||||||
@@ -100,8 +101,14 @@ export function formatTrackStats(stats: TrackStats): {
|
|||||||
speedAvgKn: string
|
speedAvgKn: string
|
||||||
} {
|
} {
|
||||||
return {
|
return {
|
||||||
distanceNm: stats.distanceNm.toFixed(2),
|
distanceNm: formatAppDecimal(stats.distanceNm, { minimumFractionDigits: 2, maximumFractionDigits: 2 }),
|
||||||
speedMaxKn: stats.speedMaxKn > 0 ? stats.speedMaxKn.toFixed(1) : '',
|
speedMaxKn:
|
||||||
speedAvgKn: stats.speedAvgKn > 0 ? stats.speedAvgKn.toFixed(1) : ''
|
stats.speedMaxKn > 0
|
||||||
|
? formatAppDecimal(stats.speedMaxKn, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
||||||
|
: '',
|
||||||
|
speedAvgKn:
|
||||||
|
stats.speedAvgKn > 0
|
||||||
|
? formatAppDecimal(stats.speedAvgKn, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
||||||
|
: ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import { parseOptionalTankLiters, tankCapacityInputFromStored } from './tankCapacity.js'
|
import { parseOptionalTankLiters, tankCapacityInputFromStored } from './tankCapacity.js'
|
||||||
|
import { formatAppDecimal, parseAppDecimal } from './numberFormat.js'
|
||||||
import type { VesselData } from '../types/vessel.js'
|
import type { VesselData } from '../types/vessel.js'
|
||||||
|
|
||||||
export function metricInputFromStored(value: unknown): string {
|
export function metricInputFromStored(value: unknown): string {
|
||||||
if (value == null || value === '') return ''
|
if (value == null || value === '') return ''
|
||||||
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return formatAppDecimal(value, { maximumFractionDigits: 6 })
|
||||||
|
}
|
||||||
if (typeof value === 'string') return value.trim()
|
if (typeof value === 'string') return value.trim()
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseOptionalMetricMeters(input: string): number | undefined {
|
export function parseOptionalMetricMeters(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_metric')
|
throw new Error('invalid_metric')
|
||||||
}
|
}
|
||||||
return parsed
|
return parsed
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { formatAppDecimal } from './numberFormat.js'
|
||||||
|
|
||||||
/** Barometric pressure (hPa), typical marine range. */
|
/** Barometric pressure (hPa), typical marine range. */
|
||||||
export const PRESSURE_MIN_HPA = 960
|
export const PRESSURE_MIN_HPA = 960
|
||||||
export const PRESSURE_MAX_HPA = 1050
|
export const PRESSURE_MAX_HPA = 1050
|
||||||
@@ -90,7 +92,9 @@ export function formatVisibilityMeters(meters: number): string {
|
|||||||
if (meters >= 1000) {
|
if (meters >= 1000) {
|
||||||
const km = meters / 1000
|
const km = meters / 1000
|
||||||
const rounded = Math.round(km * 10) / 10
|
const rounded = Math.round(km * 10) / 10
|
||||||
return Number.isInteger(rounded) ? `${rounded} km` : `${rounded.toFixed(1)} km`
|
return Number.isInteger(rounded)
|
||||||
|
? `${formatAppDecimal(rounded, { maximumFractionDigits: 0 })} km`
|
||||||
|
: `${formatAppDecimal(rounded, { minimumFractionDigits: 1, maximumFractionDigits: 1 })} km`
|
||||||
}
|
}
|
||||||
return `${Math.round(meters)} m`
|
return `${Math.round(meters)} m`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,8 +33,36 @@ function versionJsonPlugin(version: string): Plugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readPlausibleConfig(): { plausibleEnabled: boolean; plausibleHost: string } {
|
||||||
|
const host = (process.env.PLAUSIBLE_HOST || 'https://plausible.elpatron.me').replace(/\/$/, '')
|
||||||
|
const flag = (process.env.PLAUSIBLE_ENABLED ?? 'true').trim().toLowerCase()
|
||||||
|
const plausibleEnabled = !['false', '0', 'no'].includes(flag)
|
||||||
|
return { plausibleEnabled, plausibleHost: host }
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeConfigPlugin(): Plugin {
|
||||||
|
return {
|
||||||
|
name: 'runtime-config',
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use((req, res, next) => {
|
||||||
|
if (req.url !== '/runtime-config.json') return next()
|
||||||
|
res.setHeader('Content-Type', 'application/json')
|
||||||
|
res.end(`${JSON.stringify(readPlausibleConfig())}\n`)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
writeBundle(options) {
|
||||||
|
const outDir = options.dir ?? resolve(__dirname, 'dist')
|
||||||
|
writeFileSync(
|
||||||
|
resolve(outDir, 'runtime-config.json'),
|
||||||
|
`${JSON.stringify(readPlausibleConfig(), null, 2)}\n`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
envDir: resolve(__dirname, '..'),
|
||||||
test: {
|
test: {
|
||||||
environment: 'happy-dom',
|
environment: 'happy-dom',
|
||||||
include: ['src/**/*.test.ts']
|
include: ['src/**/*.test.ts']
|
||||||
@@ -59,6 +87,7 @@ export default defineConfig({
|
|||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
versionJsonPlugin(readAppVersion()),
|
versionJsonPlugin(readAppVersion()),
|
||||||
|
runtimeConfigPlugin(),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
strategies: 'injectManifest',
|
strategies: 'injectManifest',
|
||||||
srcDir: 'src',
|
srcDir: 'src',
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: daagbox-staging-db
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-daagbox_staging}
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./server
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: daagbox-staging-backend
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
PORT: 5000
|
||||||
|
DATABASE_URL: "postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-daagbox_staging}?schema=public"
|
||||||
|
RP_ID: ${RP_ID:-localhost}
|
||||||
|
ORIGIN: ${ORIGIN:-http://localhost}
|
||||||
|
TRUST_PROXY: ${TRUST_PROXY:-1}
|
||||||
|
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
|
||||||
|
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
|
||||||
|
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:support@kapteins-daagbok.eu}
|
||||||
|
OpenWeatherMapAPIKey: ${OpenWeatherMapAPIKey:-}
|
||||||
|
OpenRouterAPIKey: ${OpenRouterAPIKey:-}
|
||||||
|
OpenRouterModel: ${OpenRouterModel:-anthropic/claude-3.5-haiku}
|
||||||
|
SESSION_SECRET: ${SESSION_SECRET:-}
|
||||||
|
ADMIN_USER_IDS: ${ADMIN_USER_IDS:-}
|
||||||
|
NTFY_SERVER: ${NTFY_SERVER:-https://ntfy.sh}
|
||||||
|
NTFY_TOPIC: ${NTFY_TOPIC:-}
|
||||||
|
NTFY_TOKEN: ${NTFY_TOKEN:-}
|
||||||
|
command: sh -c "npx prisma db push && node dist/index.js"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "-e", "const http = require('http'); http.get('http://localhost:5000/api/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1); }).on('error', () => process.exit(1));"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
start_period: 60s
|
||||||
|
retries: 5
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: client/Dockerfile
|
||||||
|
args:
|
||||||
|
APP_VERSION: ${APP_VERSION:-0.1.0.0-dev}
|
||||||
|
container_name: daagbox-staging-frontend
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
PLAUSIBLE_ENABLED: ${PLAUSIBLE_ENABLED:-false}
|
||||||
|
PLAUSIBLE_HOST: ${PLAUSIBLE_HOST:-https://plausible.elpatron.me}
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
name: daagbox-staging-pgdata
|
||||||
@@ -35,10 +35,17 @@ services:
|
|||||||
OpenRouterAPIKey: ${OpenRouterAPIKey:-}
|
OpenRouterAPIKey: ${OpenRouterAPIKey:-}
|
||||||
OpenRouterModel: ${OpenRouterModel:-anthropic/claude-3.5-haiku}
|
OpenRouterModel: ${OpenRouterModel:-anthropic/claude-3.5-haiku}
|
||||||
SESSION_SECRET: ${SESSION_SECRET:-}
|
SESSION_SECRET: ${SESSION_SECRET:-}
|
||||||
|
ADMIN_USER_IDS: ${ADMIN_USER_IDS:-}
|
||||||
NTFY_SERVER: ${NTFY_SERVER:-https://ntfy.sh}
|
NTFY_SERVER: ${NTFY_SERVER:-https://ntfy.sh}
|
||||||
NTFY_TOPIC: ${NTFY_TOPIC:-}
|
NTFY_TOPIC: ${NTFY_TOPIC:-}
|
||||||
NTFY_TOKEN: ${NTFY_TOKEN:-}
|
NTFY_TOKEN: ${NTFY_TOKEN:-}
|
||||||
command: sh -c "npx prisma db push && node dist/index.js"
|
command: sh -c "npx prisma db push && node dist/index.js"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "-e", "const http = require('http'); http.get('http://localhost:5000/api/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1); }).on('error', () => process.exit(1));"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
start_period: 60s
|
||||||
|
retries: 5
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -51,6 +58,9 @@ services:
|
|||||||
APP_VERSION: ${APP_VERSION:-0.1.0.0-dev}
|
APP_VERSION: ${APP_VERSION:-0.1.0.0-dev}
|
||||||
container_name: daagbox-prod-frontend
|
container_name: daagbox-prod-frontend
|
||||||
restart: always
|
restart: always
|
||||||
|
environment:
|
||||||
|
PLAUSIBLE_ENABLED: ${PLAUSIBLE_ENABLED:-true}
|
||||||
|
PLAUSIBLE_HOST: ${PLAUSIBLE_HOST:-https://plausible.elpatron.me}
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "80:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# Server-Backup (Produktion)
|
||||||
|
|
||||||
|
Automatische und manuelle Sicherung von PostgreSQL, `.env`, `docker-compose.yml` und App-Code (Git-Archiv) auf der Prod-VM.
|
||||||
|
|
||||||
|
**Staging:** Kein automatisches Backup — Daten sind bewusst wegwerfbar. Deploy via `update-remotes.sh -dest stage` legt kein Backup an. Zum manuellen Testen auf Staging: `-dest stage` (oder Auto-Fallback, wenn nur `daagbox-staging-db` läuft).
|
||||||
|
|
||||||
|
## Was wird gesichert?
|
||||||
|
|
||||||
|
| Inhalt | Beschreibung |
|
||||||
|
|--------|--------------|
|
||||||
|
| `database.sql.gz` | `pg_dump` aus dem laufenden DB-Container |
|
||||||
|
| `.env` | Server-Secrets (Sessions, DB-Passwort, VAPID, …) |
|
||||||
|
| `docker-compose.yml` | Aktive Compose-Datei |
|
||||||
|
| `app.tar.gz` | `git archive HEAD` — Code-Snapshot |
|
||||||
|
| `manifest.json` | Timestamp, Git-Tag, SHA, Grund (`cron` / `pre-deploy` / `manual`) |
|
||||||
|
|
||||||
|
Backups liegen in `/var/backups/kapteins-daagbok/` (mode 700, root-only). Es werden **maximal 5** Archive aufbewahrt.
|
||||||
|
|
||||||
|
## Einmalige Einrichtung (Prod-Server)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh root@10.0.0.25
|
||||||
|
mkdir -p /var/backups/kapteins-daagbok
|
||||||
|
chmod 700 /var/backups/kapteins-daagbok
|
||||||
|
cd /opt/kapteins-daagbok
|
||||||
|
git pull
|
||||||
|
chmod +x scripts/backup.sh scripts/restore-backup.sh
|
||||||
|
./scripts/backup.sh --reason manual
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manuell sichern
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/kapteins-daagbok
|
||||||
|
./scripts/backup.sh
|
||||||
|
./scripts/backup.sh --reason manual --dry-run # Vorschau ohne Schreiben
|
||||||
|
```
|
||||||
|
|
||||||
|
### Staging (manueller Test)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/kapteins-daagbok-staging
|
||||||
|
./scripts/backup.sh -dest stage --reason manual
|
||||||
|
# oder: Auto-Fallback, wenn nur daagbox-staging-db läuft
|
||||||
|
./scripts/backup.sh --reason manual
|
||||||
|
```
|
||||||
|
|
||||||
|
## Crontab (unbeaufsichtigt)
|
||||||
|
|
||||||
|
Beispiel: [`scripts/crontab.prod.example`](../../scripts/crontab.prod.example)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
crontab -e
|
||||||
|
# Zeile einfügen:
|
||||||
|
0 3 * * * cd /opt/kapteins-daagbok && ./scripts/backup.sh --reason cron >> /var/log/kapteins-backup.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pre-Deploy-Backup
|
||||||
|
|
||||||
|
Bei `./scripts/update-remotes.sh -dest prod` wird **vor** dem Git-Sync auf dem Server automatisch ein Backup mit Tag `v{VERSION}-predeploy` erstellt. Schlägt das Backup fehl, wird das Deploy abgebrochen.
|
||||||
|
|
||||||
|
Staging-Deploys (`-dest stage`) erstellen **kein** Backup.
|
||||||
|
|
||||||
|
## Wiederherstellen
|
||||||
|
|
||||||
|
Verfügbare Backups anzeigen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/restore-backup.sh --list
|
||||||
|
```
|
||||||
|
|
||||||
|
Vollständige Wiederherstellung (DB + `.env`, optional Git-Tag checkout):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/restore-backup.sh --restore /var/backups/kapteins-daagbok/kapteins-daagbok_YYYYMMDD-HHMMSS_vX.Y.Z.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
Nur Datenbank:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/restore-backup.sh --restore PATH --db-only
|
||||||
|
```
|
||||||
|
|
||||||
|
Nur `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/restore-backup.sh --restore PATH --env-only
|
||||||
|
```
|
||||||
|
|
||||||
|
Ohne Rückfragen (Notfall):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/restore-backup.sh --restore PATH --full --yes
|
||||||
|
```
|
||||||
|
|
||||||
|
## Vor Passwort-Rotation
|
||||||
|
|
||||||
|
Vor [`rotate-postgres-password.sh`](../../scripts/rotate-postgres-password.sh) ein Backup anlegen — siehe auch [postgres-password.md](postgres-password.md):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/backup.sh --reason manual
|
||||||
|
```
|
||||||
|
|
||||||
|
## Umgebungsvariablen
|
||||||
|
|
||||||
|
| Variable | Prod (default) | Staging (`-dest stage`) |
|
||||||
|
|----------|----------------|-------------------------|
|
||||||
|
| `COMPOSE_FILE` | `docker-compose.yml` | `docker-compose.staging.yml` |
|
||||||
|
| `DB_CONTAINER` | `daagbox-prod-db` | `daagbox-staging-db` |
|
||||||
|
| `BACKUP_DIR` | `/var/backups/kapteins-daagbok` | gleich |
|
||||||
|
| `RETENTION` | `5` | `5` |
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
# Deployment: Nginx Proxy Manager & Security (Sprint 1)
|
# Deployment: Nginx Proxy Manager & Security (Sprint 1)
|
||||||
|
|
||||||
Kapteins Daagbok läuft öffentlich unter **https://kapteins-daagbok.eu/** hinter **Nginx Proxy Manager** (NPM, z. B. `172.16.10.10`) mit Upstream auf den App-Stack (`172.16.10.110`).
|
Kapteins Daagbok läuft öffentlich unter **https://kapteins-daagbok.eu/** (Produktion) und **https://staging.kapteins-daagbok.eu/** (Staging) hinter **Nginx Proxy Manager** (NPM, z. B. `172.16.10.10`) mit Upstream auf die App-VMs (`10.0.0.25` Prod, `10.0.0.27` Staging).
|
||||||
|
|
||||||
## NPM Proxy Host
|
## NPM Proxy Host
|
||||||
|
|
||||||
| Einstellung | Wert |
|
| Einstellung | Wert |
|
||||||
|-------------|------|
|
|-------------|------|
|
||||||
| Domain | `kapteins-daagbok.eu` |
|
| Domain | `kapteins-daagbok.eu` / `staging.kapteins-daagbok.eu` |
|
||||||
| Scheme | `https` |
|
| Scheme | `https` |
|
||||||
| Forward Hostname / IP | `172.16.10.110` (oder Container-Port auf dem Host) |
|
| Forward Hostname / IP | `10.0.0.25` (Prod) / `10.0.0.27` (Staging) |
|
||||||
| Forward Port | `80` (Frontend-Nginx) |
|
| Forward Port | `80` (Frontend-Nginx) |
|
||||||
| Websockets | an, falls genutzt |
|
| Websockets | an, falls genutzt |
|
||||||
| Block Common Exploits | an |
|
| Block Common Exploits | an |
|
||||||
@@ -40,13 +40,20 @@ TRUST_PROXY=1
|
|||||||
## Security-Header
|
## Security-Header
|
||||||
|
|
||||||
- **HSTS, CSP (optional restriktiver):** können in NPM unter „Custom Headers“ oder im Advanced-Block gesetzt werden.
|
- **HSTS, CSP (optional restriktiver):** können in NPM unter „Custom Headers“ oder im Advanced-Block gesetzt werden.
|
||||||
- **Basis-Header** für statische Dateien setzt [`client/nginx.conf`](../../client/nginx.conf) (X-Content-Type-Options, X-Frame-Options, Referrer-Policy, CSP inkl. Plausible).
|
- **Basis-Header** für statische Dateien setzt [`client/nginx.conf.template`](../../client/nginx.conf.template) via Container-Entrypoint (X-Content-Type-Options, X-Frame-Options, Referrer-Policy, CSP optional inkl. Plausible).
|
||||||
|
|
||||||
### Plausible Analytics
|
### Plausible Analytics
|
||||||
|
|
||||||
Script-Host: `https://plausible.elpatron.me` — in CSP als `script-src` und `connect-src` erlaubt. Gemessene Site: `data-domain="kapteins-daagbok.eu"`.
|
Konfiguration über `.env` (Frontend-Container):
|
||||||
|
|
||||||
Optional später: `analytics.kapteins-daagbok.eu` als Alias auf dieselbe Plausible-Instanz.
|
```env
|
||||||
|
PLAUSIBLE_ENABLED=true
|
||||||
|
PLAUSIBLE_HOST=https://plausible.elpatron.me
|
||||||
|
```
|
||||||
|
|
||||||
|
Staging-Default: `PLAUSIBLE_ENABLED=false` in [`docker-compose.staging.yml`](../../docker-compose.staging.yml).
|
||||||
|
|
||||||
|
Script-Host wird in CSP (`script-src`, `connect-src`) nur bei `PLAUSIBLE_ENABLED=true` freigegeben. `data-domain` ist immer der aktuelle Hostname (Prod vs. Staging getrennt, wenn Staging aktiviert wird).
|
||||||
|
|
||||||
## Nach Deploy prüfen
|
## Nach Deploy prüfen
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
## Empfohlene Schritte
|
## Empfohlene Schritte
|
||||||
|
|
||||||
1. **Backup/Snapshot** (hast du laut Vorgabe).
|
1. **Backup/Snapshot** — auf dem Server: `./scripts/backup.sh --reason manual` (Details: [backup.md](backup.md)).
|
||||||
2. Auf dem Server im Repo:
|
2. Auf dem Server im Repo:
|
||||||
```bash
|
```bash
|
||||||
cd /opt/kapteins-daagbok
|
cd /opt/kapteins-daagbok
|
||||||
|
|||||||
@@ -31,12 +31,12 @@ cd server && npm test
|
|||||||
|
|
||||||
## Nach erfolgreichem Check
|
## Nach erfolgreichem Check
|
||||||
|
|
||||||
[`scripts/update-prod.sh`](../../scripts/update-prod.sh) führt `predeploy-check.sh` **automatisch** aus (nach Release-Vorbereitung, vor dem SSH-Deploy).
|
[`scripts/update-remotes.sh`](../../scripts/update-remotes.sh) führt `predeploy-check.sh` **automatisch** aus (nach Release-Vorbereitung bei Prod, vor dem SSH-Deploy).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/update-prod.sh
|
./scripts/update-remotes.sh -dest prod
|
||||||
```
|
```
|
||||||
|
|
||||||
Notfall ohne Checks (nur wenn nötig): `SKIP_PREDEPLOY_CHECK=1 ./scripts/update-prod.sh`
|
Notfall ohne Checks (nur wenn nötig): `SKIP_PREDEPLOY_CHECK=1 ./scripts/update-remotes.sh -dest prod`
|
||||||
|
|
||||||
Manuell auf dem Server: `git pull`, `docker compose build`, `docker compose up -d` (siehe [npm-security.md](npm-security.md)).
|
Manuell auf dem Server: `git pull`, `docker compose build`, `docker compose up -d` (siehe [npm-security.md](npm-security.md)).
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# Staging-Umgebung
|
||||||
|
|
||||||
|
Staging läuft auf **VM3** (`10.0.0.27`) unter **https://staging.kapteins-daagbok.eu/** — hinter Nginx Proxy Manager wie Produktion.
|
||||||
|
|
||||||
|
## Unterschiede zu Produktion
|
||||||
|
|
||||||
|
| | Staging | Produktion |
|
||||||
|
|---|---------|------------|
|
||||||
|
| Host | `10.0.0.27` | `10.0.0.25` |
|
||||||
|
| Verzeichnis | `/opt/kapteins-daagbok-staging` | `/opt/kapteins-daagbok` |
|
||||||
|
| Compose | `docker-compose.staging.yml` | `docker-compose.yml` |
|
||||||
|
| Deploy-Skript | `./scripts/update-remotes.sh -dest stage` | `./scripts/update-remotes.sh -dest prod` |
|
||||||
|
| Release-Tag | nein | ja (`v*`) |
|
||||||
|
| Datenbank-Volume | `daagbox-staging-pgdata` | `daagbox-prod-pgdata` |
|
||||||
|
|
||||||
|
Staging ist **vollständig isoliert**: eigene DB, Session-Secrets, Passkeys (`RP_ID=staging.kapteins-daagbok.eu`) und optional eigene VAPID-/Ntfy-Konfiguration.
|
||||||
|
|
||||||
|
## Erstinstallation (VM3)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh root@10.0.0.27
|
||||||
|
|
||||||
|
git clone https://gitea.elpatron.me/elpatron/kapteins-daagbok.git /opt/kapteins-daagbok-staging
|
||||||
|
cd /opt/kapteins-daagbok-staging
|
||||||
|
git checkout master
|
||||||
|
|
||||||
|
# .env anlegen — Secrets neu generieren, nicht von Prod kopieren
|
||||||
|
openssl rand -hex 24 # POSTGRES_PASSWORD
|
||||||
|
openssl rand -base64 48 # SESSION_SECRET
|
||||||
|
|
||||||
|
nano .env
|
||||||
|
docker compose -f docker-compose.staging.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### `.env` (Staging)
|
||||||
|
|
||||||
|
```env
|
||||||
|
ORIGIN=https://staging.kapteins-daagbok.eu
|
||||||
|
RP_ID=staging.kapteins-daagbok.eu
|
||||||
|
TRUST_PROXY=1
|
||||||
|
|
||||||
|
POSTGRES_USER=postgres
|
||||||
|
POSTGRES_PASSWORD=<generiert>
|
||||||
|
POSTGRES_DB=daagbox_staging
|
||||||
|
|
||||||
|
SESSION_SECRET=<generiert>
|
||||||
|
|
||||||
|
NTFY_SERVER=https://ntfy.sh
|
||||||
|
NTFY_TOPIC=kapteins-daagbok-staging-feedback
|
||||||
|
|
||||||
|
# Analytics aus (Staging soll Prod-Statistik nicht verfälschen)
|
||||||
|
PLAUSIBLE_ENABLED=false
|
||||||
|
PLAUSIBLE_HOST=https://plausible.elpatron.me
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional: `VAPID_*`, `OpenWeatherMapAPIKey`, `OpenRouterAPIKey`, `ADMIN_USER_IDS`, `NTFY_TOKEN`.
|
||||||
|
|
||||||
|
## Deploy vom Entwicklungsrechner
|
||||||
|
|
||||||
|
Führt `npm run check` aus, dann SSH-Deploy ohne Release-Tag:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/update-remotes.sh -dest stage
|
||||||
|
```
|
||||||
|
|
||||||
|
Konfiguration via Umgebungsvariablen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
REMOTE_HOST=10.0.0.27 \
|
||||||
|
REMOTE_DIR=/opt/kapteins-daagbok-staging \
|
||||||
|
DEPLOY_BRANCH=master \
|
||||||
|
./scripts/update-remotes.sh -dest stage
|
||||||
|
```
|
||||||
|
|
||||||
|
Notfall ohne Checks: `SKIP_PREDEPLOY_CHECK=1 ./scripts/update-remotes.sh -dest stage`
|
||||||
|
|
||||||
|
## NPM (VM1)
|
||||||
|
|
||||||
|
| Einstellung | Wert |
|
||||||
|
|-------------|------|
|
||||||
|
| Domain | `staging.kapteins-daagbok.eu` |
|
||||||
|
| Forward Hostname / IP | `10.0.0.27` |
|
||||||
|
| Forward Port | `80` |
|
||||||
|
| SSL | Let's Encrypt |
|
||||||
|
|
||||||
|
Empfohlen: Custom Header `X-Robots-Tag: noindex, nofollow` (Staging nicht indexieren).
|
||||||
|
|
||||||
|
Details zu Proxy-Headern und Security: [npm-security.md](npm-security.md).
|
||||||
|
|
||||||
|
## Nach Deploy prüfen
|
||||||
|
|
||||||
|
1. https://staging.kapteins-daagbok.eu/api/health — `status: ok`
|
||||||
|
2. Neuen Test-Account registrieren (Prod-Passkeys funktionieren nicht auf Staging)
|
||||||
|
3. Passkey Login
|
||||||
|
4. Cookie `daagbok_session`: `Secure`, `HttpOnly`, `SameSite=Lax`
|
||||||
|
|
||||||
|
## Daten zurücksetzen
|
||||||
|
|
||||||
|
Staging-Daten sind wegwerfbar:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/kapteins-daagbok-staging
|
||||||
|
docker compose -f docker-compose.staging.yml down
|
||||||
|
docker volume rm daagbox-staging-pgdata
|
||||||
|
docker compose -f docker-compose.staging.yml up -d
|
||||||
|
```
|
||||||
BIN
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 2.8 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 359 KiB |
@@ -0,0 +1,318 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>Kapteins Daagbok — Sharepic (Portrait)</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
width: 1080px;
|
||||||
|
height: 1920px;
|
||||||
|
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||||
|
color: #e2e8f0;
|
||||||
|
background: #0f172a;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 100px 80px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 50% 10%, rgba(56, 189, 248, 0.18) 0%, transparent 45%),
|
||||||
|
radial-gradient(circle at 50% 90%, rgba(134, 59, 255, 0.22) 0%, transparent 45%),
|
||||||
|
linear-gradient(180deg, #090d16 0%, #111827 50%, #090d16 100%);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subtle background grid pattern */
|
||||||
|
body::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image: linear-gradient(rgba(148, 163, 184, 0.03) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(148, 163, 184, 0.03) 1px, transparent 1px);
|
||||||
|
background-size: 40px 40px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Outer border */
|
||||||
|
.outer-border {
|
||||||
|
position: absolute;
|
||||||
|
inset: 40px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.1);
|
||||||
|
border-radius: 30px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 30px;
|
||||||
|
z-index: 2;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 140px;
|
||||||
|
height: 140px;
|
||||||
|
object-fit: contain;
|
||||||
|
filter: drop-shadow(0 8px 24px rgba(56, 189, 248, 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-group h1 {
|
||||||
|
font-size: 64px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
color: #ffffff;
|
||||||
|
line-height: 1.1;
|
||||||
|
background: linear-gradient(135deg, #ffffff 60%, #94a3b8 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-group p {
|
||||||
|
font-size: 26px;
|
||||||
|
color: #38bdf8;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 8px;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 50px;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro-text {
|
||||||
|
font-size: 26px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-weight: 400;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro-text strong {
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.features-card {
|
||||||
|
background: rgba(30, 41, 59, 0.45);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 50px 60px;
|
||||||
|
box-shadow: 0 30px 60px rgba(0, 0, 0, 0.4);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.features-card::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 1px;
|
||||||
|
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.02));
|
||||||
|
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||||
|
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||||
|
-webkit-mask-composite: xor;
|
||||||
|
mask-composite: exclude;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-bottom: 35px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title::after {
|
||||||
|
content: "";
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(148, 163, 184, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-premium {
|
||||||
|
background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
|
||||||
|
color: #1e293b;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.features-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 30px;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 20px;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-icon {
|
||||||
|
color: #38bdf8;
|
||||||
|
font-size: 26px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 3px;
|
||||||
|
text-shadow: 0 0 10px rgba(56, 189, 248, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-section {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 50px;
|
||||||
|
z-index: 2;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-badge {
|
||||||
|
background: linear-gradient(135deg, #38bdf8 0%, #0284c7 100%);
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 800;
|
||||||
|
padding: 20px 45px;
|
||||||
|
border-radius: 16px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
box-shadow: 0 10px 30px rgba(56, 189, 248, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-code {
|
||||||
|
width: 160px;
|
||||||
|
height: 160px;
|
||||||
|
background: #ffffff;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-code img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #64748b;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer strong {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="outer-border"></div>
|
||||||
|
|
||||||
|
<div class="brand">
|
||||||
|
<img class="logo" src="../../client/public/logo.png" alt="Kapteins Daagbok" />
|
||||||
|
<div class="title-group">
|
||||||
|
<h1>Kapteins Daagbok</h1>
|
||||||
|
<p>Digitales Yacht-Logbuch</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="main-content">
|
||||||
|
<p class="intro-text">
|
||||||
|
Führe dein Bordlogbuch modern & digital: Reisetage, GPS-Tracks, Crew- und Schiffsdaten —
|
||||||
|
<strong>Ende-zu-Ende-verschlüsselt</strong>, als App installierbar und <strong>auch offline</strong> auf See nutzbar.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="features-card">
|
||||||
|
<div class="card-title">Top Features <span class="badge-premium">Kostenlos & Werbefrei</span></div>
|
||||||
|
<ul class="features-list">
|
||||||
|
<li class="feature-item">
|
||||||
|
<span class="feature-icon">✦</span>
|
||||||
|
<span>Nautisches Logbuch-Format & Streckenstatistik</span>
|
||||||
|
</li>
|
||||||
|
<li class="feature-item">
|
||||||
|
<span class="feature-icon">✦</span>
|
||||||
|
<span>Offline-first PWA — läuft auf allen Smartphones & Tablets</span>
|
||||||
|
</li>
|
||||||
|
<li class="feature-item">
|
||||||
|
<span class="feature-icon">✦</span>
|
||||||
|
<span>Ende-zu-Ende Verschlüsselung (Zero-Knowledge)</span>
|
||||||
|
</li>
|
||||||
|
<li class="feature-item">
|
||||||
|
<span class="feature-icon">✦</span>
|
||||||
|
<span>Einfache passwortlose Passkey-Anmeldung</span>
|
||||||
|
</li>
|
||||||
|
<li class="feature-item">
|
||||||
|
<span class="feature-icon">✦</span>
|
||||||
|
<span>GPS-Track-Upload & automatische NMEA-Erfassung</span>
|
||||||
|
</li>
|
||||||
|
<li class="feature-item">
|
||||||
|
<span class="feature-icon">✦</span>
|
||||||
|
<span>Crew-Einladung zur gemeinsamen Logbuch-Arbeit</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bottom-section">
|
||||||
|
<div class="cta-container">
|
||||||
|
<div class="cta-badge">
|
||||||
|
kapteins-daagbok.eu
|
||||||
|
</div>
|
||||||
|
<div class="qr-code">
|
||||||
|
<img src="assets/qr-kapteins-daagbok.eu.png" alt="QR Code" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<footer>
|
||||||
|
<strong>Kapteins Daagbok</strong> ist ein werbefreies, privates Hobbyprojekt.
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>Kapteins Daagbok — Sharepic</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
width: 1200px;
|
||||||
|
height: 630px;
|
||||||
|
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||||
|
color: #e2e8f0;
|
||||||
|
background: #0f172a;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 60px 80px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 90% 10%, rgba(56, 189, 248, 0.15) 0%, transparent 45%),
|
||||||
|
radial-gradient(circle at 10% 90%, rgba(134, 59, 255, 0.18) 0%, transparent 45%),
|
||||||
|
linear-gradient(165deg, #090d16 0%, #111827 50%, #090d16 100%);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subtle background grid pattern */
|
||||||
|
body::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image: linear-gradient(rgba(148, 163, 184, 0.03) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(148, 163, 184, 0.03) 1px, transparent 1px);
|
||||||
|
background-size: 40px 40px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Outer border */
|
||||||
|
.outer-border {
|
||||||
|
position: absolute;
|
||||||
|
inset: 30px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.1);
|
||||||
|
border-radius: 20px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
z-index: 2;
|
||||||
|
position: relative;
|
||||||
|
gap: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-col {
|
||||||
|
flex: 1.1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
object-fit: contain;
|
||||||
|
filter: drop-shadow(0 4px 12px rgba(56, 189, 248, 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-group h1 {
|
||||||
|
font-size: 44px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
color: #ffffff;
|
||||||
|
line-height: 1.1;
|
||||||
|
background: linear-gradient(135deg, #ffffff 60%, #94a3b8 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-group p {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #38bdf8;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 4px;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro-text {
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro-text strong {
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-badge {
|
||||||
|
background: linear-gradient(135deg, #38bdf8 0%, #0284c7 100%);
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 800;
|
||||||
|
padding: 14px 28px;
|
||||||
|
border-radius: 12px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
box-shadow: 0 4px 20px rgba(56, 189, 248, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-code {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
background: #ffffff;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-code img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-col {
|
||||||
|
flex: 0.9;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.features-card {
|
||||||
|
background: rgba(30, 41, 59, 0.45);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 35px;
|
||||||
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.features-card::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 1px;
|
||||||
|
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.02));
|
||||||
|
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||||
|
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||||
|
-webkit-mask-composite: xor;
|
||||||
|
mask-composite: exclude;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.features-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 14px;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-icon {
|
||||||
|
color: #38bdf8;
|
||||||
|
font-size: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 2px;
|
||||||
|
text-shadow: 0 0 8px rgba(56, 189, 248, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-premium {
|
||||||
|
background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
|
||||||
|
color: #1e293b;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title::after {
|
||||||
|
content: "";
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(148, 163, 184, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 45px;
|
||||||
|
left: 80px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer strong {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="outer-border"></div>
|
||||||
|
<div class="content-wrapper">
|
||||||
|
<div class="left-col">
|
||||||
|
<div class="brand">
|
||||||
|
<img class="logo" src="../../client/public/logo.png" alt="Kapteins Daagbok" />
|
||||||
|
<div class="title-group">
|
||||||
|
<h1>Kapteins Daagbok</h1>
|
||||||
|
<p>Digitales Yacht-Logbuch</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="intro-text">
|
||||||
|
Führe dein Bordlogbuch modern & digital: Reisetage, GPS-Tracks, Crew- und Schiffsdaten —
|
||||||
|
<strong>Ende-zu-Ende-verschlüsselt</strong>, als App installierbar und <strong>auch offline</strong> auf See nutzbar.
|
||||||
|
</p>
|
||||||
|
<div class="cta-container">
|
||||||
|
<div class="cta-badge">
|
||||||
|
kapteins-daagbok.eu
|
||||||
|
</div>
|
||||||
|
<div class="qr-code">
|
||||||
|
<img src="assets/qr-kapteins-daagbok.eu.png" alt="QR Code" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="right-col">
|
||||||
|
<div class="features-card">
|
||||||
|
<div class="card-title">Top Features <span class="badge-premium">Kostenlos & Werbefrei</span></div>
|
||||||
|
<ul class="features-list">
|
||||||
|
<li class="feature-item">
|
||||||
|
<span class="feature-icon">✦</span>
|
||||||
|
<span>Nautisches Logbuch-Format & Streckenstatistik</span>
|
||||||
|
</li>
|
||||||
|
<li class="feature-item">
|
||||||
|
<span class="feature-icon">✦</span>
|
||||||
|
<span>Offline-first PWA — läuft auf allen Smartphones & Tablets</span>
|
||||||
|
</li>
|
||||||
|
<li class="feature-item">
|
||||||
|
<span class="feature-icon">✦</span>
|
||||||
|
<span>Ende-zu-Ende Verschlüsselung (Zero-Knowledge)</span>
|
||||||
|
</li>
|
||||||
|
<li class="feature-item">
|
||||||
|
<span class="feature-icon">✦</span>
|
||||||
|
<span>Einfache passwortlose Passkey-Anmeldung</span>
|
||||||
|
</li>
|
||||||
|
<li class="feature-item">
|
||||||
|
<span class="feature-icon">✦</span>
|
||||||
|
<span>GPS-Track-Upload & automatische NMEA-Erfassung</span>
|
||||||
|
</li>
|
||||||
|
<li class="feature-item">
|
||||||
|
<span class="feature-icon">✦</span>
|
||||||
|
<span>Crew-Einladung zur gemeinsamen Logbuch-Arbeit</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<footer>
|
||||||
|
<strong>Kapteins Daagbok</strong> ist ein werbefreies, privates Hobbyprojekt.
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,12 +1,21 @@
|
|||||||
# Plausible Custom Events
|
# Plausible Custom Events
|
||||||
|
|
||||||
Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Script `script.tagged-events.js` auf der Domain `kapteins-daagbok.eu`. Custom Events werden über `window.plausible()` ausgelöst (siehe `client/src/services/analytics.ts`).
|
Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Script `script.tagged-events.js`. Custom Events werden über `window.plausible()` ausgelöst (siehe `client/src/services/analytics.ts`).
|
||||||
|
|
||||||
|
**Konfiguration** (`.env`, Frontend-Container / Vite-Dev):
|
||||||
|
|
||||||
|
```env
|
||||||
|
PLAUSIBLE_ENABLED=true # Staging: false
|
||||||
|
PLAUSIBLE_HOST=https://plausible.elpatron.me
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Script wird über `plausible-bootstrap.js` geladen; `data-domain` ist der aktuelle Hostname. CSP in Nginx enthält `PLAUSIBLE_HOST` nur wenn aktiviert.
|
||||||
|
|
||||||
**Datenschutz:** Es werden keine personenbezogenen Daten in Event-Properties übermittelt (keine Nutzernamen, Hafennamen, Koordinaten o.ä.).
|
**Datenschutz:** Es werden keine personenbezogenen Daten in Event-Properties übermittelt (keine Nutzernamen, Hafennamen, Koordinaten o.ä.).
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
1. Script in `client/index.html` (bereits eingebunden)
|
1. `PLAUSIBLE_*` in `.env` setzen (Prod: enabled, Staging: disabled empfohlen)
|
||||||
2. Nach Deploy: Goals im Plausible-Dashboard anlegen — **Namen müssen exakt mit der Event-Spalte „Event name“ übereinstimmen** (Title Case, Leerzeichen)
|
2. Nach Deploy: Goals im Plausible-Dashboard anlegen — **Namen müssen exakt mit der Event-Spalte „Event name“ übereinstimmen** (Title Case, Leerzeichen)
|
||||||
|
|
||||||
## Event-Übersicht
|
## Event-Übersicht
|
||||||
@@ -37,9 +46,8 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
|
|||||||
| CSV Exported | CSV-Download aus der Eintragsliste (`LogEntriesList.tsx`) | — |
|
| CSV Exported | CSV-Download aus der Eintragsliste (`LogEntriesList.tsx`) | — |
|
||||||
| CSV Shared | CSV über Web Share API geteilt (`LogEntriesList.tsx`) | — |
|
| CSV Shared | CSV über Web Share API geteilt (`LogEntriesList.tsx`) | — |
|
||||||
| Photo Uploaded | Foto hochgeladen (`photoAttachments.ts`, `PhotoCapture.tsx`, `CrewForm.tsx`) | `context`: `logbook` \| `live_log` \| `crew`, bei Crew zusätzlich `role`: `skipper` \| `crew` |
|
| Photo Uploaded | Foto hochgeladen (`photoAttachments.ts`, `PhotoCapture.tsx`, `CrewForm.tsx`) | `context`: `logbook` \| `live_log` \| `crew`, bei Crew zusätzlich `role`: `skipper` \| `crew` |
|
||||||
| Live Log Photo Uploaded | Foto im Live-Journal per Kamera gespeichert (`photoAttachments.ts`, `analyticsContext`: `live_log`) | — |
|
|
||||||
| Voice Memo Uploaded | Sprachnotiz gespeichert (`voiceAttachments.ts`) | `context`: `logbook` \| `live_log` |
|
| Voice Memo Uploaded | Sprachnotiz gespeichert (`voiceAttachments.ts`) | `context`: `logbook` \| `live_log` |
|
||||||
| Live Log Voice Uploaded | Sprachnotiz im Live-Journal gespeichert (`voiceAttachments.ts`, `analyticsContext`: `live_log`) | — |
|
| Voice Memo Transcribed | Sprachmemo transkribiert (`LiveLogView.tsx`, `EventRemarksCell.tsx`) | `status`: `success` \| `failed`, `mode`: `auto` (beim Speichern) \| `manual` (nachträglich) |
|
||||||
| OWM Weather Fetched | Erfolgreicher OpenWeatherMap-API-Abruf (`weather.ts`, zentral nach HTTP 200) | `source`: siehe [OWM-Quellen](#owm-quellen) |
|
| OWM Weather Fetched | Erfolgreicher OpenWeatherMap-API-Abruf (`weather.ts`, zentral nach HTTP 200) | `source`: siehe [OWM-Quellen](#owm-quellen) |
|
||||||
| AI Summary Generated | Erfolgreiche KI-Zusammenfassung eines Reisetags (`aiSummary.ts`) | — |
|
| AI Summary Generated | Erfolgreiche KI-Zusammenfassung eines Reisetags (`aiSummary.ts`) | — |
|
||||||
| Backup Exported | Backup-Datei heruntergeladen (`LogbookBackupPanel.tsx`, v2 ZIP) | `entries`, `photos`, `voiceMemos`, `bytes` (Anzahlen/Größe, keine Inhalte) |
|
| Backup Exported | Backup-Datei heruntergeladen (`LogbookBackupPanel.tsx`, v2 ZIP) | `entries`, `photos`, `voiceMemos`, `bytes` (Anzahlen/Größe, keine Inhalte) |
|
||||||
@@ -47,6 +55,7 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
|
|||||||
| Push Enabled | Crew-Änderungs-Push aktiviert (`PushNotificationSettings.tsx`) | — |
|
| Push Enabled | Crew-Änderungs-Push aktiviert (`PushNotificationSettings.tsx`) | — |
|
||||||
| Push Disabled | Crew-Änderungs-Push deaktiviert (`PushNotificationSettings.tsx`) | — |
|
| Push Disabled | Crew-Änderungs-Push deaktiviert (`PushNotificationSettings.tsx`) | — |
|
||||||
| Footer Link Clicked | Klick auf Autoren-Link im App-Footer (`AppFooter.tsx`) | — |
|
| Footer Link Clicked | Klick auf Autoren-Link im App-Footer (`AppFooter.tsx`) | — |
|
||||||
|
| Ko-fi Link Clicked | Klick auf Ko-fi-Unterstützen-Badge im App-Footer (`AppFooter.tsx`) | — |
|
||||||
| Profile Opened | Profilseite geöffnet (`UserProfilePage.tsx`, einmal pro Mount) | — |
|
| Profile Opened | Profilseite geöffnet (`UserProfilePage.tsx`, einmal pro Mount) | — |
|
||||||
| Passkey Added | Passkey erfolgreich registriert (`UserProfilePage.tsx`) | `labeled`: `true` \| `false` (optionaler Name gesetzt) |
|
| Passkey Added | Passkey erfolgreich registriert (`UserProfilePage.tsx`) | `labeled`: `true` \| `false` (optionaler Name gesetzt) |
|
||||||
| Passkey Removed | Passkey entfernt, mindestens ein Key verbleibt (`UserProfilePage.tsx`) | — |
|
| Passkey Removed | Passkey entfernt, mindestens ein Key verbleibt (`UserProfilePage.tsx`) | — |
|
||||||
@@ -85,7 +94,7 @@ Property `action` bei **Live Log Event Logged** — stabile englische Schlüssel
|
|||||||
| `temp` | Temperatur |
|
| `temp` | Temperatur |
|
||||||
| `precip` | Niederschlag |
|
| `precip` | Niederschlag |
|
||||||
| `sea_state` | Seegang |
|
| `sea_state` | Seegang |
|
||||||
| `fix` | GPS-Fix (manuell) |
|
| `position` | GPS-Position (manuell) |
|
||||||
| `comment` | Kommentar |
|
| `comment` | Kommentar |
|
||||||
| `voice` | Sprachnotiz (Modal gespeichert) |
|
| `voice` | Sprachnotiz (Modal gespeichert) |
|
||||||
| `undo` | Letztes Ereignis rückgängig |
|
| `undo` | Letztes Ereignis rückgängig |
|
||||||
@@ -138,7 +147,7 @@ Empfohlene Goal-Ketten für Auswertung (nur Business!):
|
|||||||
7. **Kontosicherheit:** Profile Opened → Passkey Added / Local PIN Set / Recovery Rotated; Last Passkey Remove Hinted → Account Deleted (selten, aber aussagekräftig)
|
7. **Kontosicherheit:** Profile Opened → Passkey Added / Local PIN Set / Recovery Rotated; Last Passkey Remove Hinted → Account Deleted (selten, aber aussagekräftig)
|
||||||
8. **Internationalisierung:** Language Changed (Verteilung `to`, Pfade mit Übersetzungs-Feedback)
|
8. **Internationalisierung:** Language Changed (Verteilung `to`, Pfade mit Übersetzungs-Feedback)
|
||||||
9. **NMEA-Import:** NMEA Uploaded → NMEA Imported (Modus, `events`, optional Track; Upload-Funnel vs. Abbruch)
|
9. **NMEA-Import:** NMEA Uploaded → NMEA Imported (Modus, `events`, optional Track; Upload-Funnel vs. Abbruch)
|
||||||
10. **Live-Journal:** Live Log Opened → Live Log Event Logged (Verteilung `action`; z. B. `fix`, `course`, `motor_start`) → Live Log Photo Uploaded
|
10. **Live-Journal:** Live Log Opened → Live Log Event Logged (Verteilung `action`; z. B. `position`, `course`, `motor_start`) → Photo Uploaded / Voice Memo Uploaded (Filter `context`: `live_log`)
|
||||||
11. **OpenWeatherMap:** OWM Weather Fetched (Verteilung `source`; Live-Journal vs. Reisetag-Editor)
|
11. **OpenWeatherMap:** OWM Weather Fetched (Verteilung `source`; Live-Journal vs. Reisetag-Editor)
|
||||||
12. **PWA-Stabilitaet:** PWA Boot Watchdog Soft → PWA Boot Watchdog Hard → PWA Boot Watchdog Fallback → PWA Boot Watchdog Manual Repair
|
12. **PWA-Stabilitaet:** PWA Boot Watchdog Soft → PWA Boot Watchdog Hard → PWA Boot Watchdog Fallback → PWA Boot Watchdog Manual Repair
|
||||||
|
|
||||||
@@ -151,7 +160,9 @@ trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
|
|||||||
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'skipper' })
|
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'skipper' })
|
||||||
trackPlausibleEvent(PlausibleEvents.LANGUAGE_CHANGED, { from: 'de', to: 'da' })
|
trackPlausibleEvent(PlausibleEvents.LANGUAGE_CHANGED, { from: 'de', to: 'da' })
|
||||||
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'course' })
|
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_EVENT_LOGGED, { action: 'course' })
|
||||||
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_PHOTO_UPLOADED)
|
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: 'live_log' })
|
||||||
|
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_UPLOADED, { context: 'live_log' })
|
||||||
|
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, { status: 'success', mode: 'auto' })
|
||||||
trackPlausibleEvent(PlausibleEvents.OWM_WEATHER_FETCHED, { source: 'live_log' })
|
trackPlausibleEvent(PlausibleEvents.OWM_WEATHER_FETCHED, { source: 'live_log' })
|
||||||
trackPlausibleEvent(PlausibleEvents.NMEA_UPLOADED, { lines: 1200, candidates: 8, duplicate: false, has_position: true })
|
trackPlausibleEvent(PlausibleEvents.NMEA_UPLOADED, { lines: 1200, candidates: 8, duplicate: false, has_position: true })
|
||||||
trackPlausibleEvent(PlausibleEvents.NMEA_IMPORTED, { mode: 'both', events: 6, track: true })
|
trackPlausibleEvent(PlausibleEvents.NMEA_IMPORTED, { mode: 'both', events: 6, track: true })
|
||||||
|
|||||||
Executable
+255
@@ -0,0 +1,255 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Create a local backup of PostgreSQL, .env, docker-compose and app (git archive).
|
||||||
|
#
|
||||||
|
# Run on the server in repo root (/opt/kapteins-daagbok on production).
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/backup.sh
|
||||||
|
# ./scripts/backup.sh -dest stage # Staging-Container (daagbox-staging-db)
|
||||||
|
# ./scripts/backup.sh --reason cron
|
||||||
|
# ./scripts/backup.sh --reason pre-deploy --tag v0.1.1.20
|
||||||
|
# ./scripts/backup.sh --dry-run
|
||||||
|
#
|
||||||
|
# Environment overrides:
|
||||||
|
# BACKUP_DIR, COMPOSE_FILE, DB_CONTAINER, RETENTION, ENV_FILE
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BACKUP_DIR="${BACKUP_DIR:-/var/backups/kapteins-daagbok}"
|
||||||
|
ENV_FILE="${ENV_FILE:-.env}"
|
||||||
|
RETENTION="${RETENTION:-5}"
|
||||||
|
DEST="prod"
|
||||||
|
REASON="manual"
|
||||||
|
EXPLICIT_TAG=""
|
||||||
|
DRY_RUN=0
|
||||||
|
COMPOSE_FILE=""
|
||||||
|
DB_CONTAINER=""
|
||||||
|
|
||||||
|
apply_dest_config() {
|
||||||
|
local dest="$1"
|
||||||
|
local force="${2:-0}"
|
||||||
|
if [[ "$dest" == "stage" ]]; then
|
||||||
|
if [[ "$force" == "1" || -z "${COMPOSE_FILE}" ]]; then
|
||||||
|
COMPOSE_FILE="docker-compose.staging.yml"
|
||||||
|
fi
|
||||||
|
if [[ "$force" == "1" || -z "${DB_CONTAINER}" ]]; then
|
||||||
|
DB_CONTAINER="daagbox-staging-db"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if [[ "$force" == "1" || -z "${COMPOSE_FILE}" ]]; then
|
||||||
|
COMPOSE_FILE="docker-compose.yml"
|
||||||
|
fi
|
||||||
|
if [[ "$force" == "1" || -z "${DB_CONTAINER}" ]]; then
|
||||||
|
DB_CONTAINER="daagbox-prod-db"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
sed -n '2,14p' "$0"
|
||||||
|
echo ""
|
||||||
|
echo "Options:"
|
||||||
|
echo " -dest prod|stage Target environment (default: prod)"
|
||||||
|
echo " --reason cron|pre-deploy|manual Backup trigger (default: manual)"
|
||||||
|
echo " --tag TAG Git tag label (e.g. v0.1.1.20 for pre-deploy)"
|
||||||
|
echo " --dry-run Show actions without writing backup"
|
||||||
|
echo " -h, --help Show this help"
|
||||||
|
}
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
-dest)
|
||||||
|
DEST="${2:?-dest requires an argument}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-dest=*)
|
||||||
|
DEST="${1#*=}"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--reason)
|
||||||
|
REASON="${2:?--reason requires an argument}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--tag)
|
||||||
|
EXPLICIT_TAG="${2:?--tag requires an argument}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--dry-run)
|
||||||
|
DRY_RUN=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown option: $1" >&2
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
case "$DEST" in
|
||||||
|
prod|stage) ;;
|
||||||
|
*)
|
||||||
|
echo "Error: invalid -dest '$DEST' (use prod or stage)" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
apply_dest_config "$DEST"
|
||||||
|
|
||||||
|
case "$REASON" in
|
||||||
|
cron|pre-deploy|manual) ;;
|
||||||
|
*)
|
||||||
|
echo "Error: invalid --reason '$REASON' (use cron, pre-deploy, or manual)" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ ! -f "$COMPOSE_FILE" ]; then
|
||||||
|
echo "Error: $COMPOSE_FILE not found" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
GIT_SHA="$(git rev-parse HEAD 2>/dev/null || echo unknown)"
|
||||||
|
GIT_TAG="$EXPLICIT_TAG"
|
||||||
|
if [ -z "$GIT_TAG" ]; then
|
||||||
|
GIT_TAG="$(git describe --tags --exact-match HEAD 2>/dev/null || true)"
|
||||||
|
fi
|
||||||
|
if [ -z "$GIT_TAG" ] && [ -f VERSION ]; then
|
||||||
|
GIT_TAG="v$(tr -d '[:space:]' < VERSION)"
|
||||||
|
fi
|
||||||
|
if [ -z "$GIT_TAG" ]; then
|
||||||
|
GIT_TAG="unknown"
|
||||||
|
fi
|
||||||
|
|
||||||
|
APP_VERSION="$(tr -d '[:space:]' < VERSION 2>/dev/null || echo unknown)"
|
||||||
|
TAG_SLUG="${GIT_TAG}"
|
||||||
|
if [ "$REASON" = "pre-deploy" ]; then
|
||||||
|
TAG_SLUG="${GIT_TAG}-predeploy"
|
||||||
|
fi
|
||||||
|
TAG_SLUG="${TAG_SLUG//\//-}"
|
||||||
|
|
||||||
|
ARCHIVE_NAME="kapteins-daagbok_${TIMESTAMP}_${TAG_SLUG}.tar.gz"
|
||||||
|
ARCHIVE_PATH="${BACKUP_DIR}/${ARCHIVE_NAME}"
|
||||||
|
|
||||||
|
echo "Backup: reason=$REASON tag=$GIT_TAG sha=${GIT_SHA:0:8} → $ARCHIVE_PATH"
|
||||||
|
|
||||||
|
if [ "$DRY_RUN" -eq 1 ]; then
|
||||||
|
echo "[dry-run] Would dump database from $DB_CONTAINER"
|
||||||
|
echo "[dry-run] Would copy $ENV_FILE and $COMPOSE_FILE"
|
||||||
|
echo "[dry-run] Would create git archive"
|
||||||
|
echo "[dry-run] Would write manifest and pack to $ARCHIVE_PATH"
|
||||||
|
echo "[dry-run] Would apply retention (keep $RETENTION)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$ENV_FILE" ]; then
|
||||||
|
echo "Error: $ENV_FILE not found (run from repo root)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
set -a
|
||||||
|
source "$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
POSTGRES_USER="${POSTGRES_USER:-postgres}"
|
||||||
|
POSTGRES_DB="${POSTGRES_DB:-daagbox}"
|
||||||
|
|
||||||
|
if [ -z "${POSTGRES_PASSWORD:-}" ]; then
|
||||||
|
echo "Error: POSTGRES_PASSWORD not set in $ENV_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! docker inspect "$DB_CONTAINER" >/dev/null 2>&1; then
|
||||||
|
if [[ "$DEST" == "prod" ]] && docker inspect daagbox-staging-db >/dev/null 2>&1; then
|
||||||
|
echo "Note: $DB_CONTAINER not found — falling back to staging (daagbox-staging-db). Use -dest stage explicitly."
|
||||||
|
apply_dest_config stage 1
|
||||||
|
DEST="stage"
|
||||||
|
else
|
||||||
|
echo "Error: DB container '$DB_CONTAINER' not found" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$(docker inspect -f '{{.State.Running}}' "$DB_CONTAINER")" != "true" ]; then
|
||||||
|
echo "Error: DB container '$DB_CONTAINER' is not running" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
chmod 700 "$BACKUP_DIR"
|
||||||
|
|
||||||
|
WORK_DIR="$(mktemp -d)"
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
echo "Dumping PostgreSQL ($POSTGRES_DB)..."
|
||||||
|
export PGPASSWORD="$POSTGRES_PASSWORD"
|
||||||
|
if ! docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$DB_CONTAINER" \
|
||||||
|
pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --no-owner --no-acl \
|
||||||
|
| gzip > "$WORK_DIR/database.sql.gz"; then
|
||||||
|
echo "Error: pg_dump failed" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
unset PGPASSWORD
|
||||||
|
|
||||||
|
cp "$ENV_FILE" "$WORK_DIR/.env"
|
||||||
|
chmod 600 "$WORK_DIR/.env"
|
||||||
|
cp "$COMPOSE_FILE" "$WORK_DIR/docker-compose.yml"
|
||||||
|
|
||||||
|
echo "Creating app snapshot (git archive)..."
|
||||||
|
if git archive --format=tar HEAD | gzip > "$WORK_DIR/app.tar.gz"; then
|
||||||
|
:
|
||||||
|
else
|
||||||
|
echo "Warning: git archive failed — backup continues without app.tar.gz" >&2
|
||||||
|
rm -f "$WORK_DIR/app.tar.gz"
|
||||||
|
fi
|
||||||
|
|
||||||
|
python3 - "$WORK_DIR/manifest.json" <<PY
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
manifest = {
|
||||||
|
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
|
"local_timestamp": "${TIMESTAMP}",
|
||||||
|
"destination": "${DEST}",
|
||||||
|
"reason": "${REASON}",
|
||||||
|
"git_tag": "${GIT_TAG}",
|
||||||
|
"git_sha": "${GIT_SHA}",
|
||||||
|
"app_version": "${APP_VERSION}",
|
||||||
|
"compose_file": "${COMPOSE_FILE}",
|
||||||
|
"db_container": "${DB_CONTAINER}",
|
||||||
|
"postgres_db": "${POSTGRES_DB}",
|
||||||
|
"hostname": socket.gethostname(),
|
||||||
|
"archive_name": "${ARCHIVE_NAME}",
|
||||||
|
}
|
||||||
|
with open(sys.argv[1], "w", encoding="utf-8") as f:
|
||||||
|
json.dump(manifest, f, indent=2)
|
||||||
|
f.write("\n")
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo "Packing backup archive..."
|
||||||
|
tar -czf "$ARCHIVE_PATH" -C "$WORK_DIR" \
|
||||||
|
manifest.json database.sql.gz .env docker-compose.yml \
|
||||||
|
$( [ -f "$WORK_DIR/app.tar.gz" ] && echo app.tar.gz )
|
||||||
|
chmod 600 "$ARCHIVE_PATH"
|
||||||
|
|
||||||
|
echo "Applying retention (keep last $RETENTION backups)..."
|
||||||
|
mapfile -t ALL_BACKUPS < <(ls -1t "$BACKUP_DIR"/kapteins-daagbok_*.tar.gz 2>/dev/null || true)
|
||||||
|
if [ "${#ALL_BACKUPS[@]}" -gt "$RETENTION" ]; then
|
||||||
|
for ((i = RETENTION; i < ${#ALL_BACKUPS[@]}; i++)); do
|
||||||
|
echo "Removing old backup: ${ALL_BACKUPS[$i]}"
|
||||||
|
rm -f "${ALL_BACKUPS[$i]}"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Backup complete: $ARCHIVE_PATH"
|
||||||
|
echo "$ARCHIVE_PATH"
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Kapteins Daagbok — Production backup cron (install on 10.0.0.25)
|
||||||
|
#
|
||||||
|
# Install:
|
||||||
|
# crontab -e
|
||||||
|
# (paste the line below)
|
||||||
|
#
|
||||||
|
# Ensure log directory exists:
|
||||||
|
# touch /var/log/kapteins-backup.log && chmod 600 /var/log/kapteins-backup.log
|
||||||
|
|
||||||
|
# Daily backup at 03:00 UTC — keeps last 5 in /var/backups/kapteins-daagbok/
|
||||||
|
0 3 * * * cd /opt/kapteins-daagbok && ./scripts/backup.sh --reason cron >> /var/log/kapteins-backup.log 2>&1
|
||||||
Executable
+95
@@ -0,0 +1,95 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Generates the sharepic PNGs (landscape & portrait) from HTML files
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/generate-sharepic.mjs
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execSync } from 'node:child_process'
|
||||||
|
import { dirname, resolve } from 'node:path'
|
||||||
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||||
|
import { createRequire } from 'node:module'
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const repoRoot = resolve(__dirname, '..')
|
||||||
|
const clientDir = resolve(repoRoot, 'client')
|
||||||
|
const marketingDir = resolve(repoRoot, 'docs/marketing')
|
||||||
|
|
||||||
|
const require = createRequire(resolve(clientDir, 'package.json'))
|
||||||
|
|
||||||
|
function isMissingBrowserError(err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err)
|
||||||
|
return msg.includes("Executable doesn't exist") || msg.includes('browserType.launch')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensurePlaywrightChromium(playwright) {
|
||||||
|
try {
|
||||||
|
const browser = await playwright.chromium.launch({ headless: true })
|
||||||
|
await browser.close()
|
||||||
|
return
|
||||||
|
} catch (err) {
|
||||||
|
if (!isMissingBrowserError(err)) throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Playwright Chromium fehlt — installiere Browser (einmalig)…')
|
||||||
|
execSync('npx playwright install chromium', {
|
||||||
|
cwd: clientDir,
|
||||||
|
stdio: 'inherit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPlaywright() {
|
||||||
|
try {
|
||||||
|
return require('playwright')
|
||||||
|
} catch {
|
||||||
|
console.error('Fehlende Abhängigkeit: "npm install -D playwright" in client/ ausführen.')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderSharepic(browser, htmlName, pngName, width, height) {
|
||||||
|
const htmlPath = resolve(marketingDir, htmlName)
|
||||||
|
const pngPath = resolve(marketingDir, pngName)
|
||||||
|
|
||||||
|
console.log(`Generating sharepic (${width}x${height}) from ${htmlName}...`)
|
||||||
|
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width, height },
|
||||||
|
deviceScaleFactor: 2 // High-DPI for crisp text
|
||||||
|
})
|
||||||
|
const page = await context.newPage()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.goto(pathToFileURL(htmlPath).href, { waitUntil: 'networkidle' })
|
||||||
|
await page.screenshot({
|
||||||
|
path: pngPath,
|
||||||
|
type: 'png'
|
||||||
|
})
|
||||||
|
console.log('Successfully wrote:', pngPath)
|
||||||
|
} finally {
|
||||||
|
await page.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const playwright = loadPlaywright()
|
||||||
|
await ensurePlaywrightChromium(playwright)
|
||||||
|
|
||||||
|
const browser = await playwright.chromium.launch({ headless: true })
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Landscape 1200x630
|
||||||
|
await renderSharepic(browser, 'sharepic.html', 'kapteins-daagbok-sharepic.png', 1200, 630)
|
||||||
|
|
||||||
|
// Portrait 1080x1920
|
||||||
|
await renderSharepic(browser, 'sharepic-portrait.html', 'kapteins-daagbok-sharepic-portrait.png', 1080, 1920)
|
||||||
|
} finally {
|
||||||
|
await browser.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('Error generating sharepics:', err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
Executable
+345
@@ -0,0 +1,345 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Restore server backup created by scripts/backup.sh
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/restore-backup.sh --list
|
||||||
|
# ./scripts/restore-backup.sh -dest stage --restore PATH
|
||||||
|
# ./scripts/restore-backup.sh --restore /var/backups/kapteins-daagbok/kapteins-daagbok_....tar.gz
|
||||||
|
#
|
||||||
|
# Environment overrides:
|
||||||
|
# BACKUP_DIR, COMPOSE_FILE, DB_CONTAINER, BACKEND_CONTAINER, ENV_FILE
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BACKUP_DIR="${BACKUP_DIR:-/var/backups/kapteins-daagbok}"
|
||||||
|
ENV_FILE="${ENV_FILE:-.env}"
|
||||||
|
MAX_WAIT=90
|
||||||
|
DEST="prod"
|
||||||
|
COMPOSE_FILE=""
|
||||||
|
DB_CONTAINER=""
|
||||||
|
BACKEND_CONTAINER=""
|
||||||
|
|
||||||
|
apply_dest_config() {
|
||||||
|
local dest="$1"
|
||||||
|
local force="${2:-0}"
|
||||||
|
if [[ "$dest" == "stage" ]]; then
|
||||||
|
if [[ "$force" == "1" || -z "${COMPOSE_FILE}" ]]; then
|
||||||
|
COMPOSE_FILE="docker-compose.staging.yml"
|
||||||
|
fi
|
||||||
|
if [[ "$force" == "1" || -z "${DB_CONTAINER}" ]]; then
|
||||||
|
DB_CONTAINER="daagbox-staging-db"
|
||||||
|
fi
|
||||||
|
if [[ "$force" == "1" || -z "${BACKEND_CONTAINER}" ]]; then
|
||||||
|
BACKEND_CONTAINER="daagbox-staging-backend"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if [[ "$force" == "1" || -z "${COMPOSE_FILE}" ]]; then
|
||||||
|
COMPOSE_FILE="docker-compose.yml"
|
||||||
|
fi
|
||||||
|
if [[ "$force" == "1" || -z "${DB_CONTAINER}" ]]; then
|
||||||
|
DB_CONTAINER="daagbox-prod-db"
|
||||||
|
fi
|
||||||
|
if [[ "$force" == "1" || -z "${BACKEND_CONTAINER}" ]]; then
|
||||||
|
BACKEND_CONTAINER="daagbox-prod-backend"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
MODE="full"
|
||||||
|
RESTORE_PATH=""
|
||||||
|
LIST=0
|
||||||
|
ASSUME_YES=0
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
sed -n '2,10p' "$0"
|
||||||
|
echo ""
|
||||||
|
echo "Options:"
|
||||||
|
echo " -dest prod|stage Target environment (default: prod)"
|
||||||
|
echo " --list List available backups"
|
||||||
|
echo " --restore PATH Backup archive to restore"
|
||||||
|
echo " --full Restore DB + .env (default)"
|
||||||
|
echo " --db-only Restore database only"
|
||||||
|
echo " --env-only Restore .env only"
|
||||||
|
echo " --yes Skip confirmation prompts"
|
||||||
|
echo " -h, --help Show this help"
|
||||||
|
}
|
||||||
|
|
||||||
|
confirm() {
|
||||||
|
local prompt="$1"
|
||||||
|
if [ "$ASSUME_YES" -eq 1 ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
read -r -p "$prompt [y/N] " answer
|
||||||
|
[[ "$answer" =~ ^[yY]$ ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
-dest)
|
||||||
|
DEST="${2:?-dest requires an argument}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-dest=*)
|
||||||
|
DEST="${1#*=}"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--list)
|
||||||
|
LIST=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--restore)
|
||||||
|
RESTORE_PATH="${2:?--restore requires a path}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--full)
|
||||||
|
MODE="full"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--db-only)
|
||||||
|
MODE="db-only"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--env-only)
|
||||||
|
MODE="env-only"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--yes)
|
||||||
|
ASSUME_YES=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown option: $1" >&2
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
case "$DEST" in
|
||||||
|
prod|stage) ;;
|
||||||
|
*)
|
||||||
|
echo "Error: invalid -dest '$DEST' (use prod or stage)" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
apply_dest_config "$DEST"
|
||||||
|
|
||||||
|
list_backups() {
|
||||||
|
if [ ! -d "$BACKUP_DIR" ]; then
|
||||||
|
echo "No backup directory: $BACKUP_DIR"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local found=0
|
||||||
|
while IFS= read -r archive; do
|
||||||
|
found=1
|
||||||
|
echo "=== $archive ==="
|
||||||
|
tar -xOf "$archive" manifest.json 2>/dev/null | python3 -m json.tool 2>/dev/null || echo "(no manifest)"
|
||||||
|
echo ""
|
||||||
|
done < <(ls -1t "$BACKUP_DIR"/kapteins-daagbok_*.tar.gz 2>/dev/null || true)
|
||||||
|
|
||||||
|
if [ "$found" -eq 0 ]; then
|
||||||
|
echo "No backups found in $BACKUP_DIR"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$LIST" -eq 1 ]; then
|
||||||
|
list_backups
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$RESTORE_PATH" ]; then
|
||||||
|
echo "Error: --restore PATH or --list required" >&2
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$RESTORE_PATH" ]; then
|
||||||
|
echo "Error: backup archive not found: $RESTORE_PATH" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
WORK_DIR="$(mktemp -d)"
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
echo "Extracting $RESTORE_PATH..."
|
||||||
|
tar -xzf "$RESTORE_PATH" -C "$WORK_DIR"
|
||||||
|
|
||||||
|
if [ ! -f "$WORK_DIR/manifest.json" ]; then
|
||||||
|
echo "Error: manifest.json missing in backup archive" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
MANIFEST="$WORK_DIR/manifest.json"
|
||||||
|
echo "Backup manifest:"
|
||||||
|
python3 -m json.tool "$MANIFEST"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
read_manifest_field() {
|
||||||
|
python3 -c "import json; print(json.load(open('$MANIFEST')).get('$1', '') or '')"
|
||||||
|
}
|
||||||
|
|
||||||
|
MANIFEST_COMPOSE="$(read_manifest_field compose_file)"
|
||||||
|
MANIFEST_DB="$(read_manifest_field db_container)"
|
||||||
|
MANIFEST_DEST="$(read_manifest_field destination)"
|
||||||
|
|
||||||
|
if [ -n "$MANIFEST_COMPOSE" ]; then
|
||||||
|
COMPOSE_FILE="$MANIFEST_COMPOSE"
|
||||||
|
fi
|
||||||
|
if [ -n "$MANIFEST_DB" ]; then
|
||||||
|
DB_CONTAINER="$MANIFEST_DB"
|
||||||
|
fi
|
||||||
|
if [ -n "$MANIFEST_DEST" ]; then
|
||||||
|
if [[ "$MANIFEST_DEST" == "stage" ]]; then
|
||||||
|
BACKEND_CONTAINER="${BACKEND_CONTAINER:-daagbox-staging-backend}"
|
||||||
|
else
|
||||||
|
BACKEND_CONTAINER="${BACKEND_CONTAINER:-daagbox-prod-backend}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! docker inspect "$DB_CONTAINER" >/dev/null 2>&1; then
|
||||||
|
if [[ "$DEST" == "prod" ]] && docker inspect daagbox-staging-db >/dev/null 2>&1; then
|
||||||
|
echo "Note: $DB_CONTAINER not found — falling back to staging (daagbox-staging-db). Use -dest stage explicitly."
|
||||||
|
apply_dest_config stage 1
|
||||||
|
DEST="stage"
|
||||||
|
else
|
||||||
|
echo "Error: DB container '$DB_CONTAINER' not found" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
GIT_TAG="$(read_manifest_field git_tag)"
|
||||||
|
GIT_SHA="$(read_manifest_field git_sha)"
|
||||||
|
BACKUP_TS="$(read_manifest_field local_timestamp)"
|
||||||
|
|
||||||
|
if ! confirm "Restore backup from $BACKUP_TS (tag: $GIT_TAG)? Mode: $MODE"; then
|
||||||
|
echo "Aborted."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
restore_env() {
|
||||||
|
if [ ! -f "$WORK_DIR/.env" ]; then
|
||||||
|
echo "Error: .env missing in backup archive" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$ENV_FILE" ]; then
|
||||||
|
BAK="${ENV_FILE}.bak-restore.$(date +%Y%m%d-%H%M%S)"
|
||||||
|
cp "$ENV_FILE" "$BAK"
|
||||||
|
echo "Current $ENV_FILE saved to $BAK"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cp "$WORK_DIR/.env" "$ENV_FILE"
|
||||||
|
chmod 600 "$ENV_FILE"
|
||||||
|
echo "Restored $ENV_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
restore_db() {
|
||||||
|
if [ ! -f "$WORK_DIR/database.sql.gz" ]; then
|
||||||
|
echo "Error: database.sql.gz missing in backup archive" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$ENV_FILE" ]; then
|
||||||
|
echo "Error: $ENV_FILE required for database restore" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
set -a
|
||||||
|
source "$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
POSTGRES_USER="${POSTGRES_USER:-postgres}"
|
||||||
|
POSTGRES_DB="${POSTGRES_DB:-daagbox}"
|
||||||
|
|
||||||
|
if [ -z "${POSTGRES_PASSWORD:-}" ]; then
|
||||||
|
echo "Error: POSTGRES_PASSWORD not set in $ENV_FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! docker inspect "$DB_CONTAINER" >/dev/null 2>&1; then
|
||||||
|
echo "Error: DB container '$DB_CONTAINER' not found" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Stopping backend before database restore..."
|
||||||
|
docker compose -f "$COMPOSE_FILE" stop backend || true
|
||||||
|
|
||||||
|
echo "Resetting public schema..."
|
||||||
|
export PGPASSWORD="$POSTGRES_PASSWORD"
|
||||||
|
docker exec -e PGPASSWORD="$POSTGRES_PASSWORD" "$DB_CONTAINER" \
|
||||||
|
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1 <<SQL
|
||||||
|
DROP SCHEMA IF EXISTS public CASCADE;
|
||||||
|
CREATE SCHEMA public;
|
||||||
|
GRANT ALL ON SCHEMA public TO "${POSTGRES_USER}";
|
||||||
|
GRANT ALL ON SCHEMA public TO public;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
echo "Importing database dump..."
|
||||||
|
gunzip -c "$WORK_DIR/database.sql.gz" | docker exec -i -e PGPASSWORD="$POSTGRES_PASSWORD" "$DB_CONTAINER" \
|
||||||
|
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1
|
||||||
|
unset PGPASSWORD
|
||||||
|
|
||||||
|
echo "Database restore complete."
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_healthy() {
|
||||||
|
echo "Starting stack and waiting for health..."
|
||||||
|
docker compose -f "$COMPOSE_FILE" up -d
|
||||||
|
|
||||||
|
local counter=0
|
||||||
|
while [ "$counter" -lt "$MAX_WAIT" ]; do
|
||||||
|
local status
|
||||||
|
status="$(docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$BACKEND_CONTAINER" 2>/dev/null || true)"
|
||||||
|
if [ "$status" = "healthy" ]; then
|
||||||
|
echo "Backend is healthy."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if curl -sf "http://127.0.0.1/api/health" | grep -q '"status":"ok"'; then
|
||||||
|
echo "API health check OK."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
counter=$((counter + 1))
|
||||||
|
printf "."
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
echo "Warning: backend did not become healthy in time." >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$MODE" in
|
||||||
|
env-only)
|
||||||
|
restore_env
|
||||||
|
;;
|
||||||
|
db-only)
|
||||||
|
restore_db
|
||||||
|
wait_for_healthy || exit 1
|
||||||
|
;;
|
||||||
|
full)
|
||||||
|
restore_env
|
||||||
|
restore_db
|
||||||
|
wait_for_healthy || exit 1
|
||||||
|
if [ -f "$WORK_DIR/app.tar.gz" ] && [ "$GIT_TAG" != "unknown" ]; then
|
||||||
|
if confirm "Checkout app code at tag $GIT_TAG? (git fetch + checkout)"; then
|
||||||
|
git fetch --tags origin
|
||||||
|
git checkout "$GIT_TAG"
|
||||||
|
echo "Checked out $GIT_TAG"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Error: unknown mode $MODE" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "Restore finished (mode: $MODE, tag: $GIT_TAG, sha: ${GIT_SHA:0:8})."
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user