Compare commits
117 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eab7b86c0b | |||
| b86789ae4c | |||
| 2a8ec2fccf | |||
| 60a8533a44 | |||
| c86ac4273c | |||
| 73467f2263 | |||
| e068f083c1 | |||
| f083294db5 | |||
| 8fc15081e2 | |||
| efa0fcf934 | |||
| c1ecdcad9c | |||
| d6c7952af8 | |||
| 3d02f841a0 | |||
| 0caaf681d8 | |||
| 43dc994c4f | |||
| d94502097e | |||
| a36ca2facb | |||
| b7a1085d52 | |||
| 3925c6f822 | |||
| 0b2c1c22c6 | |||
| aa03573e1f | |||
| a0b8664e23 | |||
| 74282f50d0 | |||
| 5b47415d55 | |||
| 039e4e2736 | |||
| 35bfbc1043 | |||
| 6c866dbad5 | |||
| bb667afec8 | |||
| beee33f842 | |||
| 77a7072b77 | |||
| bd1edd89f3 | |||
| ffe6b19818 | |||
| eb1f87f57e | |||
| 13cb03646b | |||
| 1bc0d7fb2a | |||
| 5f3d76b30f | |||
| b48545e943 | |||
| 3749f87c1d | |||
| 2e656dc6b2 | |||
| 484ed66b7b | |||
| 49d77f08a2 | |||
| 951b5b3f1c | |||
| abb708c3d0 | |||
| cc87b0f8e6 | |||
| 58984594b0 | |||
| 61675e1085 | |||
| 2082218f78 | |||
| 5882edcbdf | |||
| b7a47a1d90 | |||
| 48c408302f | |||
| 2b5c5d4a36 | |||
| 7cf04b3357 | |||
| bbd4281dcb | |||
| d2833f7664 | |||
| 2a14080b5b | |||
| 2457fa41e3 | |||
| 87b0fa7bde | |||
| d90f292a21 | |||
| 9e42f828a0 | |||
| 4197e77b1e | |||
| 1373c11de8 | |||
| 0bae3b29dc | |||
| 73e86d28b3 | |||
| ad4721e694 | |||
| 8037b3b63e | |||
| c4cd566da0 | |||
| 3a267905b0 | |||
| c856c2e903 | |||
| b3256d1685 | |||
| 23fc940324 | |||
| 25e1bdded3 | |||
| 6a61c9e06c | |||
| d3683ad6aa | |||
| ef5891ba3f | |||
| d25095bab3 | |||
| 0d16782001 | |||
| b7e2d470a9 | |||
| 520ba766a3 | |||
| c215cd8b15 | |||
| 27c780d2b8 | |||
| aa52948ddc | |||
| 49b4e7b9c3 | |||
| 2d64987ada | |||
| 87973eaa4a | |||
| 93e26b7807 | |||
| 814eeadd1f | |||
| d9cbcd8e43 | |||
| 282e7ba8ba | |||
| b86e5a15d6 | |||
| eac86ec655 | |||
| a6331bea1a | |||
| ae89b131a1 | |||
| 3fdea31c4a | |||
| 04d114c315 | |||
| 3fa66f044c | |||
| a84c611402 | |||
| f12b9b2a1a | |||
| 34914b4f19 | |||
| d9fa8c0edf | |||
| adf02acd45 | |||
| 3992db9d61 | |||
| 51f6a1b291 | |||
| 0b07d8b3d3 | |||
| a07e033e62 | |||
| bbe63dfb47 | |||
| 57f63ad486 | |||
| 728c40f936 | |||
| 72cbad8d5e | |||
| 15f2172a38 | |||
| e2e038f2d6 | |||
| 634eb622fd | |||
| 04b822b263 | |||
| ee60d5fda3 | |||
| 3a7d244433 | |||
| 9e03fcda0a | |||
| 34c7d2d65c | |||
| 658bc6c0c9 |
+8
-4
@@ -1,13 +1,17 @@
|
||||
OpenWeatherMapAPIKey=<owm_api_key>
|
||||
|
||||
# DeepL API (for scripts/translate-locales.mjs and scripts/translate-flyer.mjs)
|
||||
# Free plan keys use api-free.deepl.com automatically (suffix :fx)
|
||||
DeepLAPIKey=
|
||||
|
||||
# Passkey configuration (WebAuthn Relying Party ID and Origin)
|
||||
# For local dev: localhost and http://localhost
|
||||
# For local dev: use localhost (NOT 127.0.0.1 — browsers reject IP addresses for Passkeys)
|
||||
# For production: e.g. kapteins-daagbok.eu and https://kapteins-daagbok.eu
|
||||
RP_ID=localhost
|
||||
# Must match the frontend URL (Vite dev: http://localhost:5173; Docker: http://localhost)
|
||||
# Must match the frontend URL exactly (Vite dev: http://localhost:5173; Docker: http://localhost)
|
||||
ORIGIN=http://localhost:5173
|
||||
# Optional: comma-separated CORS origins (defaults to ORIGIN; dev also allows 127.0.0.1:5173)
|
||||
# CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
||||
# Optional: comma-separated CORS origins (defaults to ORIGIN; 127.0.0.1 may be allowed for CORS but not for login)
|
||||
# CORS_ORIGINS=http://localhost:5173
|
||||
|
||||
# API session signing (min. 32 chars; required in production)
|
||||
# Generate: openssl rand -base64 48
|
||||
|
||||
@@ -11,3 +11,5 @@ server/dist/
|
||||
.env.local
|
||||
.env.*.local
|
||||
*.log
|
||||
|
||||
userfeedback/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Digitales Yacht-Logbuch als Progressive Web App (PWA) — **kostenlos**, **werbefrei**, offline-fähig, End-to-End-verschlüsselt und mit Passkey-Anmeldung.
|
||||
|
||||
**Live:** [kapteins-daagbok.eu](https://kapteins-daagbok.eu)
|
||||
**Live:** [kapteins-daagbok.eu](https://kapteins-daagbok.eu) · **Demo:** [kapteins-daagbok.eu/demo](https://kapteins-daagbok.eu/demo)
|
||||
|
||||
## Überblick
|
||||
|
||||
@@ -15,19 +15,29 @@ Alle sensiblen Inhalte werden **clientseitig verschlüsselt** (Web Crypto API).
|
||||
- **Passkey-Authentifizierung** (WebAuthn) mit optionaler Recovery-Phrase und lokalem PIN-Fallback
|
||||
- **Mehrere Logbücher** pro Benutzerkonto — eigene Logbücher und per Einladung geteilte Logbücher (Crew-Zugang) klar getrennt
|
||||
- **Reisetage** mit Hafen, Wetter, Tankständen, Ereignissen und Tagesnummer
|
||||
- **Kompass-Dial** für MgK- und RwK-Kurse — Ring-Eingabe, Gradfeld, Schrittweite 1°/5°/10° (maritime Orientierung: 0° = Nord)
|
||||
- **GPS-Tracks** (GPX/KML/GeoJSON-Upload, Karte, Statistiken)
|
||||
- **Foto-Anhänge** pro Reisetag
|
||||
- **Passkey-Signaturen** für Skipper und Crew (hybride elektronische Signatur)
|
||||
- **Schiffsdaten** und **Crew-Profile** (Skipper + Mitglieder)
|
||||
- **Statistik-Dashboard** — Strecken, Verbrauch, Segel/Motor, Hafenkette (pro Logbuch oder accountweit)
|
||||
- **Benutzerprofil** — kontoweite Einstellungen: Darstellung (Theme, Hell/Dunkel), OpenWeatherMap-API-Key, Web Push, PWA-Installation, Onboarding-Tour, Passkey-Verwaltung, Account-Statistik
|
||||
- **Kollaboration** — Crew per Einladungslink einladen (Schreib- oder Lesezugriff)
|
||||
- **Push-Benachrichtigungen** (optional) — Logbuch-Eigner per Web Push informieren, wenn Crew Änderungen synchronisiert (ohne Klartext-Inhalte; Opt-in in den Einstellungen)
|
||||
- **Push-Benachrichtigungen** (optional) — Logbuch-Eigner per Web Push informieren, wenn Crew Änderungen synchronisiert (ohne Klartext-Inhalte; Opt-in im Benutzerprofil)
|
||||
- **Read-only-Freigabe** — öffentlicher Lese-Link für Dritte
|
||||
- **Export** — PDF pro Reisetag, CSV-Download/-Teilen
|
||||
- **Backup & Wiederherstellung** — vollständiges verschlüsseltes Logbuch-Backup (Einträge, Fotos, GPS, Crew, Schiff) als `.daagbok.json`; Restore auf gleichem oder neuem Account
|
||||
- **Feedback** — Bug-, Feature- und allgemeine Rückmeldungen aus der App (serverseitig via [Ntfy](https://ntfy.sh) oder self-hosted)
|
||||
- **PWA** — installierbar auf iOS/Android, Offline-Modus, Update-Hinweise
|
||||
- **Mehrsprachig** — Deutsch und Englisch
|
||||
- **Demo-Logbuch & Onboarding-Tour** für neue Nutzer
|
||||
- **Demo-Logbuch & Onboarding-Tour** für neue Nutzer (auch unter `/demo` ohne Anmeldung)
|
||||
|
||||
### Benutzerprofil vs. Logbuch-Einstellungen
|
||||
|
||||
| Bereich | Inhalt |
|
||||
|---------|--------|
|
||||
| **Benutzerprofil** | Theme, Farbschema, Wetter-API-Key, Push, PWA, Tour, Passkeys, Account löschen |
|
||||
| **Logbuch-Einstellungen** | Crew-Einladungen, öffentliche Freigabe, Backup & Wiederherstellung (nur Eigner) |
|
||||
|
||||
## Architektur
|
||||
|
||||
@@ -48,6 +58,7 @@ Alle sensiblen Inhalte werden **clientseitig verschlüsselt** (Web Crypto API).
|
||||
| Auth | WebAuthn (Passkeys) + signiertes HttpOnly-Session-Cookie (`daagbok_session`) |
|
||||
| Krypto | Web Crypto API (AES-GCM), BIP39 Recovery |
|
||||
| Push (optional) | Web Push (VAPID), Custom Service Worker (`injectManifest`) |
|
||||
| Feedback (optional) | Ntfy (HTTP Publish) |
|
||||
|
||||
### Rollen & Zugriff
|
||||
|
||||
@@ -73,7 +84,7 @@ Skipper- und Crew-Profile im Logbuch sind **Inhaltsdaten** (verschlüsselt), nic
|
||||
|
||||
## Backup & Wiederherstellung
|
||||
|
||||
Nur der **Logbuch-Eigner** kann unter **Einstellungen → Backup & Wiederherstellung** ein vollständiges Backup erstellen:
|
||||
Nur der **Logbuch-Eigner** kann unter **Logbuch-Einstellungen → Backup & Wiederherstellung** ein vollständiges Backup erstellen:
|
||||
|
||||
1. Backup-Passphrase wählen (min. 8 Zeichen, getrennt von der Datei aufbewahren)
|
||||
2. Download als `.daagbok.json` — enthält alle verschlüsselten Payloads inkl. **Fotos** und GPS-Tracks
|
||||
@@ -83,7 +94,7 @@ Vor dem Löschen eines Logbuchs weist die App auf diese Funktion hin. Crew-Einla
|
||||
|
||||
## Push-Benachrichtigungen (optional)
|
||||
|
||||
Logbuch-**Eigner** können unter **Einstellungen** Web Push aktivieren. Sobald ein eingeladenes Crewmitglied mit Schreibrechten (`WRITE`) Änderungen synchronisiert, erhält der Owner eine **generische** Benachrichtigung — der Server kennt keine Logbuch-Inhalte (Zero-Knowledge).
|
||||
Logbuch-**Eigner** können im **Benutzerprofil** Web Push aktivieren. Sobald ein eingeladenes Crewmitglied mit Schreibrechten (`WRITE`) Änderungen synchronisiert, erhält der Owner eine **generische** Benachrichtigung — der Server kennt keine Logbuch-Inhalte (Zero-Knowledge).
|
||||
|
||||
| Aspekt | Verhalten |
|
||||
|--------|-----------|
|
||||
@@ -102,20 +113,32 @@ Schlüssel erzeugen: `npx web-push generate-vapid-keys` (im `server/`-Verzeichni
|
||||
|
||||
Ausführlicher Implementierungs- und Testplan: [docs/push-notifications-plan.md](docs/push-notifications-plan.md).
|
||||
|
||||
## Feedback (optional)
|
||||
|
||||
Eingeloggte Nutzer können über das Feedback-Formular in der App Rückmeldungen senden. Der Server leitet sie an einen **Ntfy**-Topic weiter (kein Klartext-Logbuch auf dem Server).
|
||||
|
||||
| Variable | Bedeutung |
|
||||
|----------|-----------|
|
||||
| `NTFY_SERVER` | Basis-URL (Standard: `https://ntfy.sh`) |
|
||||
| `NTFY_TOPIC` | Topic-Name (ohne URL) |
|
||||
| `NTFY_TOKEN` | Optional: Access-Token für geschützte Topics |
|
||||
|
||||
Ohne `NTFY_TOPIC` antwortet die API mit „nicht konfiguriert“. Rate-Limiting und einfacher Spam-Schutz sind serverseitig aktiv.
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
```
|
||||
kapteins-daagbok/
|
||||
├── client/ # React-PWA (Frontend)
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # UI-Komponenten
|
||||
│ │ ├── components/ # UI (u. a. CourseDialInput, UserProfilePage, FeedbackModal)
|
||||
│ │ ├── services/ # Auth, Sync, Krypto, Backup, Push, Analytics, …
|
||||
│ │ ├── sw.ts # Service Worker (Precache + Web Push)
|
||||
│ │ └── i18n/ # DE/EN-Übersetzungen
|
||||
│ └── Dockerfile # Nginx-Produktions-Image
|
||||
├── server/ # Express-API + Prisma
|
||||
│ ├── src/routes/ # auth, logbooks, sync, collaboration, sign, push
|
||||
│ ├── src/services/ # z. B. pushNotify (Web Push)
|
||||
│ ├── src/routes/ # auth, logbooks, sync, collaboration, sign, push, feedback, weather
|
||||
│ ├── src/services/ # z. B. pushNotify, ntfyNotify
|
||||
│ └── prisma/ # Datenbankschema
|
||||
├── docs/ # Projektdokumentation
|
||||
├── scripts/ # Dev- und Deploy-Skripte
|
||||
@@ -128,8 +151,9 @@ kapteins-daagbok/
|
||||
- **Node.js** 20+
|
||||
- **npm**
|
||||
- **Docker** (für PostgreSQL in der Entwicklung oder den vollständigen Stack)
|
||||
- Optional: eigener OpenWeatherMap-API-Key in den Einstellungen (sonst serverseitiger Key aus `.env`)
|
||||
- Optional: eigener OpenWeatherMap-API-Key im **Benutzerprofil** (sonst serverseitiger Key aus `.env`)
|
||||
- Optional: VAPID-Schlüssel für Web Push (siehe Abschnitt Push-Benachrichtigungen)
|
||||
- Optional: Ntfy-Topic für Feedback (siehe Abschnitt Feedback)
|
||||
|
||||
## Lokale Entwicklung
|
||||
|
||||
@@ -166,6 +190,10 @@ SESSION_SECRET= # openssl rand -base64 48 (in Prod Pflicht)
|
||||
VAPID_PUBLIC_KEY=
|
||||
VAPID_PRIVATE_KEY=
|
||||
VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu
|
||||
# Optional — Feedback via Ntfy
|
||||
NTFY_SERVER=https://ntfy.sh
|
||||
NTFY_TOPIC=
|
||||
NTFY_TOKEN=
|
||||
```
|
||||
|
||||
`./scripts/start-dev.sh` prüft `ORIGIN` und `SESSION_SECRET` beim Start und gibt Hinweise aus.
|
||||
@@ -189,6 +217,15 @@ cd server && npx prisma db push && cd ..
|
||||
| Frontend (Vite) | http://localhost:5173 |
|
||||
| Backend API | http://localhost:5000 |
|
||||
| Health Check | http://localhost:5000/api/health |
|
||||
| Public Demo | http://localhost:5173/demo |
|
||||
|
||||
### 5. Tests (Frontend)
|
||||
|
||||
```bash
|
||||
cd client && npm test
|
||||
```
|
||||
|
||||
Vitest-Unit-Tests für Utils, i18n und Services (z. B. Kurswinkel, Benutzereinstellungen).
|
||||
|
||||
## Docker (produktionsnah)
|
||||
|
||||
@@ -198,9 +235,9 @@ Gesamten Stack lokal bauen und starten:
|
||||
./scripts/start-dev-docker.sh
|
||||
```
|
||||
|
||||
Frontend: http://localhost · API: http://localhost/api/health
|
||||
Frontend: http://localhost · API: http://localhost/api/health · Demo: http://localhost/demo
|
||||
|
||||
Umgebungsvariablen in `.env` setzen — mindestens `RP_ID`, `ORIGIN` (z. B. `http://localhost`) und `SESSION_SECRET`. Für Push die VAPID-Variablen an den Backend-Container durchreichen (`docker-compose.yml` → `backend.environment`).
|
||||
Umgebungsvariablen in `.env` setzen — mindestens `RP_ID`, `ORIGIN` (z. B. `http://localhost`) und `SESSION_SECRET`. Für Push die VAPID-Variablen an den Backend-Container durchreichen (`docker-compose.yml` → `backend.environment`). Für Feedback `NTFY_*` setzen.
|
||||
|
||||
## Deployment
|
||||
|
||||
@@ -212,7 +249,7 @@ Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen):
|
||||
|
||||
Standard-Ziel: `root@10.0.0.25:/opt/kapteins-daagbok` — per `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_DIR` überschreibbar.
|
||||
|
||||
Auf dem Server müssen `server/.env` (oder gleichwertige Umgebung) u. a. `DATABASE_URL`, `RP_ID`, `ORIGIN`, `SESSION_SECRET` (≥ 32 Zeichen) und bei Push `VAPID_*` enthalten. Nach Schema-Änderungen: `npx prisma db push` im Backend-Container.
|
||||
Auf dem Server müssen `server/.env` (oder gleichwertige Umgebung) u. a. `DATABASE_URL`, `RP_ID`, `ORIGIN`, `SESSION_SECRET` (≥ 32 Zeichen) und bei Push `VAPID_*` enthalten. Optional `NTFY_*` für Feedback. Nach Schema-Änderungen: `npx prisma db push` im Backend-Container.
|
||||
|
||||
## Dokumentation
|
||||
|
||||
@@ -220,6 +257,7 @@ Auf dem Server müssen `server/.env` (oder gleichwertige Umgebung) u. a. `DATABA
|
||||
|----------|--------|
|
||||
| [docs/plausible-events.md](docs/plausible-events.md) | Custom Events für Plausible Analytics |
|
||||
| [docs/push-notifications-plan.md](docs/push-notifications-plan.md) | Web Push: Architektur, API, Testplan |
|
||||
| [docs/plan-compass-course-dial.md](docs/plan-compass-course-dial.md) | Kompass-Dial: UX- und Implementierungsplan |
|
||||
| [docs/marketing/kapteins-daagbok-beta-flyer.pdf](docs/marketing/kapteins-daagbok-beta-flyer.pdf) | Beta-Flyer (DIN A4) zum Ausdrucken — Quelle: `docs/marketing/beta-flyer.html`, neu erzeugen: `cd client && npm run generate:flyer` |
|
||||
| [.planning/PROJECT.md](.planning/PROJECT.md) | Produktvision und Anforderungen (GSD) |
|
||||
|
||||
|
||||
+5
-1
@@ -12,12 +12,16 @@
|
||||
<link rel="canonical" href="https://kapteins-daagbok.eu/" />
|
||||
<link rel="alternate" hreflang="de" href="https://kapteins-daagbok.eu/?lng=de" />
|
||||
<link rel="alternate" hreflang="en" href="https://kapteins-daagbok.eu/?lng=en" />
|
||||
<link rel="alternate" hreflang="da" href="https://kapteins-daagbok.eu/?lng=da" />
|
||||
<link rel="alternate" hreflang="sv" href="https://kapteins-daagbok.eu/?lng=sv" />
|
||||
<link rel="alternate" hreflang="nb" href="https://kapteins-daagbok.eu/?lng=nb" />
|
||||
<link rel="alternate" hreflang="x-default" href="https://kapteins-daagbok.eu/" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Daagbok" />
|
||||
<meta name="theme-color" content="#1e293b" />
|
||||
<meta name="theme-color" content="#0b0c10" />
|
||||
<script src="/appearance-bootstrap.js"></script>
|
||||
<link rel="apple-touch-icon" href="/logo.png" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Kapteins Daagbok" />
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ server {
|
||||
client_max_body_size 50M;
|
||||
|
||||
# Service worker and app shell must revalidate so PWA updates are detected
|
||||
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest)$ {
|
||||
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ {
|
||||
root /usr/share/nginx/html;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
}
|
||||
|
||||
Generated
+534
-31
@@ -17,6 +17,7 @@
|
||||
"jspdf": "^4.2.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^1.16.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-i18next": "^17.0.8"
|
||||
@@ -25,6 +26,7 @@
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^24.12.3",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
@@ -32,12 +34,13 @@
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"happy-dom": "^20.9.0",
|
||||
"playwright": "^1.51.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.59.2",
|
||||
"vite": "^6.3.5",
|
||||
"vite-plugin-pwa": "^1.0.1"
|
||||
"vite-plugin-pwa": "^1.0.1",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
@@ -2896,6 +2899,24 @@
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/deep-eql": "*",
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/esrecurse": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
||||
@@ -2950,6 +2971,16 @@
|
||||
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/qrcode": {
|
||||
"version": "1.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/raf": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
|
||||
@@ -2991,6 +3022,23 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/whatwg-mimetype": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
|
||||
"integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz",
|
||||
@@ -3255,6 +3303,131 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
|
||||
"integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "3.2.4",
|
||||
"@vitest/utils": "3.2.4",
|
||||
"chai": "^5.2.0",
|
||||
"tinyrainbow": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
|
||||
"integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "3.2.4",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker/node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
|
||||
"integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
|
||||
"integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "3.2.4",
|
||||
"pathe": "^2.0.3",
|
||||
"strip-literal": "^3.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
|
||||
"integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "3.2.4",
|
||||
"magic-string": "^0.30.17",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
|
||||
"integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyspy": "^4.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
|
||||
"integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "3.2.4",
|
||||
"loupe": "^3.1.4",
|
||||
"tinyrainbow": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
@@ -3299,7 +3472,6 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -3309,7 +3481,6 @@
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
@@ -3360,6 +3531,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/async": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||
@@ -3541,6 +3722,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cac": {
|
||||
"version": "6.7.14",
|
||||
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
||||
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
|
||||
@@ -3595,7 +3786,6 @@
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@@ -3642,11 +3832,37 @@
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "5.3.3",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
|
||||
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"assertion-error": "^2.0.1",
|
||||
"check-error": "^2.1.1",
|
||||
"deep-eql": "^5.0.1",
|
||||
"loupe": "^3.1.0",
|
||||
"pathval": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/check-error": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
|
||||
"integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
@@ -3658,7 +3874,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
@@ -3671,7 +3886,6 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
@@ -3842,12 +4056,21 @@
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-eql": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
|
||||
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
@@ -3921,7 +4144,6 @@
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
@@ -3976,9 +4198,21 @@
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/es-abstract": {
|
||||
"version": "1.24.2",
|
||||
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
|
||||
@@ -4068,6 +4302,13 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
||||
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||
@@ -4406,6 +4647,16 @@
|
||||
"url": "https://github.com/bgub/eta?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -4699,7 +4950,6 @@
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
@@ -4857,6 +5107,24 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/happy-dom": {
|
||||
"version": "20.9.0",
|
||||
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.9.0.tgz",
|
||||
"integrity": "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": ">=20.0.0",
|
||||
"@types/whatwg-mimetype": "^3.0.2",
|
||||
"@types/ws": "^8.18.1",
|
||||
"entities": "^7.0.1",
|
||||
"whatwg-mimetype": "^3.0.0",
|
||||
"ws": "^8.18.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/has-bigints": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
|
||||
@@ -5231,7 +5499,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -5710,6 +5977,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loupe": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
||||
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
@@ -5934,7 +6208,6 @@
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@@ -5957,7 +6230,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -6007,6 +6279,23 @@
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pathval": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
|
||||
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14.16"
|
||||
}
|
||||
},
|
||||
"node_modules/performance-now": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
|
||||
@@ -6085,7 +6374,6 @@
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
@@ -6167,7 +6455,6 @@
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
@@ -6362,7 +6649,6 @@
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -6382,7 +6668,6 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
@@ -6554,7 +6839,6 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/set-function-length": {
|
||||
@@ -6705,6 +6989,13 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/signal-exit": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||
@@ -6773,6 +7064,13 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stackblur-canvas": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
|
||||
@@ -6783,6 +7081,13 @@
|
||||
"node": ">=0.1.14"
|
||||
}
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "3.10.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
||||
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stop-iteration-iterator": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
|
||||
@@ -6801,7 +7106,6 @@
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
@@ -6918,7 +7222,6 @@
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
@@ -6937,6 +7240,26 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-literal": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
|
||||
"integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^9.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-literal/node_modules/js-tokens": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
|
||||
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/supports-preserve-symlinks-flag": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
||||
@@ -7018,6 +7341,20 @@
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
|
||||
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.16",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||
@@ -7035,6 +7372,36 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tinypool": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
|
||||
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.0.0 || >=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyrainbow": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
|
||||
"integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyspy": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
|
||||
"integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz",
|
||||
@@ -7439,6 +7806,29 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite-node": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
|
||||
"integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cac": "^6.7.14",
|
||||
"debug": "^4.4.1",
|
||||
"es-module-lexer": "^1.7.0",
|
||||
"pathe": "^2.0.3",
|
||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
|
||||
},
|
||||
"bin": {
|
||||
"vite-node": "vite-node.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/vite-plugin-pwa": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.3.0.tgz",
|
||||
@@ -7470,6 +7860,79 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
"@vitest/mocker": "3.2.4",
|
||||
"@vitest/pretty-format": "^3.2.4",
|
||||
"@vitest/runner": "3.2.4",
|
||||
"@vitest/snapshot": "3.2.4",
|
||||
"@vitest/spy": "3.2.4",
|
||||
"@vitest/utils": "3.2.4",
|
||||
"chai": "^5.2.0",
|
||||
"debug": "^4.4.1",
|
||||
"expect-type": "^1.2.1",
|
||||
"magic-string": "^0.30.17",
|
||||
"pathe": "^2.0.3",
|
||||
"picomatch": "^4.0.2",
|
||||
"std-env": "^3.9.0",
|
||||
"tinybench": "^2.9.0",
|
||||
"tinyexec": "^0.3.2",
|
||||
"tinyglobby": "^0.2.14",
|
||||
"tinypool": "^1.1.1",
|
||||
"tinyrainbow": "^2.0.0",
|
||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
|
||||
"vite-node": "3.2.4",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"vitest": "vitest.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@edge-runtime/vm": "*",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
|
||||
"@vitest/browser": "3.2.4",
|
||||
"@vitest/ui": "3.2.4",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edge-runtime/vm": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/debug": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/ui": {
|
||||
"optional": true
|
||||
},
|
||||
"happy-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"jsdom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/void-elements": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||
@@ -7486,6 +7949,16 @@
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
|
||||
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz",
|
||||
@@ -7585,7 +8058,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/which-typed-array": {
|
||||
@@ -7610,6 +8082,23 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"siginfo": "^2.0.0",
|
||||
"stackback": "0.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"why-is-node-running": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
@@ -7844,7 +8333,6 @@
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
@@ -7855,11 +8343,32 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
@@ -7873,7 +8382,6 @@
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
@@ -7896,7 +8404,6 @@
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
@@ -7910,7 +8417,6 @@
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
@@ -7924,7 +8430,6 @@
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
@@ -7937,7 +8442,6 @@
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
@@ -7953,7 +8457,6 @@
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
|
||||
+13
-4
@@ -7,9 +7,15 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run",
|
||||
"preview": "vite preview",
|
||||
"generate:flyer": "node ../scripts/generate-beta-flyer.mjs",
|
||||
"generate:flyer:setup": "playwright install chromium"
|
||||
"generate:flyer:png": "node ../scripts/generate-beta-flyer.mjs --png",
|
||||
"generate:flyer:all": "node ../scripts/generate-beta-flyer.mjs --all",
|
||||
"generate:flyer:setup": "playwright install chromium",
|
||||
"translate:locales": "node ../scripts/translate-locales.mjs",
|
||||
"translate:flyer": "node ../scripts/translate-flyer.mjs",
|
||||
"validate:i18n": "node ../scripts/validate-i18n-keys.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
@@ -23,12 +29,14 @@
|
||||
"lucide-react": "^1.16.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-i18next": "^17.0.8"
|
||||
"react-i18next": "^17.0.8",
|
||||
"qrcode": "^1.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^24.12.3",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
@@ -36,12 +44,13 @@
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"happy-dom": "^20.9.0",
|
||||
"playwright": "^1.51.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.59.2",
|
||||
"vite": "^6.3.5",
|
||||
"vite-plugin-pwa": "^1.0.1"
|
||||
"vite-plugin-pwa": "^1.0.1",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Applies saved appearance classes before CSS/JS bundle loads (prevents wrong flash on PWA).
|
||||
* Logic mirrors client/src/services/appearance.ts + userPreferences.ts.
|
||||
*/
|
||||
(function () {
|
||||
try {
|
||||
var uid = localStorage.getItem('active_userid')
|
||||
var theme = 'auto'
|
||||
var scheme = 'auto'
|
||||
|
||||
if (uid) {
|
||||
theme =
|
||||
localStorage.getItem('user_pref_theme_' + uid) ||
|
||||
localStorage.getItem('active_theme') ||
|
||||
'auto'
|
||||
scheme =
|
||||
localStorage.getItem('user_pref_color_scheme_' + uid) ||
|
||||
localStorage.getItem('active_color_scheme') ||
|
||||
'auto'
|
||||
} else {
|
||||
theme = localStorage.getItem('active_theme') || 'auto'
|
||||
scheme = localStorage.getItem('active_color_scheme') || 'auto'
|
||||
}
|
||||
|
||||
var resolvedTheme = theme
|
||||
if (resolvedTheme !== 'ocean' && resolvedTheme !== 'material' && resolvedTheme !== 'cupertino') {
|
||||
var ua = navigator.userAgent || navigator.vendor || ''
|
||||
if (/iPad|iPhone|iPod|Macintosh/.test(ua)) resolvedTheme = 'cupertino'
|
||||
else if (/Android|Linux/.test(ua)) resolvedTheme = 'material'
|
||||
else resolvedTheme = 'ocean'
|
||||
}
|
||||
|
||||
var resolvedScheme = scheme
|
||||
if (resolvedScheme !== 'light' && resolvedScheme !== 'dark') {
|
||||
resolvedScheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
var root = document.documentElement
|
||||
root.classList.add('theme-' + resolvedTheme, 'scheme-' + resolvedScheme)
|
||||
root.style.colorScheme = resolvedScheme
|
||||
} catch (_) {
|
||||
/* ignore storage / matchMedia errors */
|
||||
}
|
||||
})()
|
||||
+1313
-10
File diff suppressed because it is too large
Load Diff
+180
-54
@@ -1,5 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import './App.css'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { DialogProvider } from './components/ModalDialog.tsx'
|
||||
import AuthOnboarding from './components/AuthOnboarding.tsx'
|
||||
import UserProfilePage from './components/UserProfilePage.tsx'
|
||||
@@ -15,7 +14,13 @@ import InvitationAcceptance from './components/InvitationAcceptance.tsx'
|
||||
import AppTourOverlay from './components/AppTourOverlay.tsx'
|
||||
import { AppTourProvider, useAppTour, type AppTab } from './context/AppTourContext.tsx'
|
||||
import { UnsavedChangesProvider, useUnsavedChangesContext } from './context/UnsavedChangesContext.tsx'
|
||||
import { getActiveMasterKey, logoutUser, checkServerSession } from './services/auth.js'
|
||||
import {
|
||||
logoutUser,
|
||||
checkServerSession,
|
||||
hasUnlockedLocalSession,
|
||||
persistSessionUserId
|
||||
} from './services/auth.js'
|
||||
import AppErrorBoundary from './components/AppErrorBoundary.tsx'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from './services/analytics.js'
|
||||
import {
|
||||
applyAppearanceToDocument,
|
||||
@@ -23,6 +28,7 @@ import {
|
||||
resolveColorScheme,
|
||||
subscribeToSystemColorScheme
|
||||
} from './services/appearance.js'
|
||||
import { syncAppearancePrefs } from './services/appearancePrefs.js'
|
||||
import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks, subscribeToSyncState } from './services/sync.js'
|
||||
import ReadOnlyViewer from './components/ReadOnlyViewer.tsx'
|
||||
import DemoViewer from './components/DemoViewer.tsx'
|
||||
@@ -38,9 +44,11 @@ import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, Languages, BarChart2 } from 'lucide-react'
|
||||
import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
|
||||
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
|
||||
import ProfileHeaderButton from './components/ProfileHeaderButton.tsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cycleAppLanguage } from './utils/i18nLanguages.js'
|
||||
import {
|
||||
getStoredDemoFirstEntryId,
|
||||
resolveTourLogbookContext,
|
||||
seedDemoLogbookIfNeeded
|
||||
} from './services/demoLogbook.js'
|
||||
import { fetchLogbooks, parseCollaborationRole } from './services/logbook.js'
|
||||
@@ -51,7 +59,7 @@ const PENDING_PUSH_LOGBOOK_KEY = 'pending_push_logbook_id'
|
||||
function App() {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { confirmLeave } = useUnsavedChangesContext()
|
||||
const { registerNavigation, requestStartAfterLogin, isActive, currentStepId } = useAppTour()
|
||||
const { registerNavigation, registerDemoTourContext, requestStartAfterLogin, isActive, currentStepId } = useAppTour()
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||
const [activeLogbookId, setActiveLogbookId] = useState<string | null>(null)
|
||||
const [activeLogbookTitle, setActiveLogbookTitle] = useState<string | null>(null)
|
||||
@@ -63,6 +71,12 @@ function App() {
|
||||
const [isSyncing, setIsSyncing] = useState(false)
|
||||
const [isAcceptingInvite, setIsAcceptingInvite] = useState(false)
|
||||
const [showUserProfile, setShowUserProfile] = useState(false)
|
||||
const tourLogbookRef = useRef<{ id: string; title: string } | null>(null)
|
||||
const activeLogbookRef = useRef<{ id: string | null; title: string | null }>({
|
||||
id: activeLogbookId,
|
||||
title: activeLogbookTitle
|
||||
})
|
||||
activeLogbookRef.current = { id: activeLogbookId, title: activeLogbookTitle }
|
||||
|
||||
// Viewer mode for read-only shared links
|
||||
const [isViewerMode, setIsViewerMode] = useState(false)
|
||||
@@ -139,6 +153,13 @@ function App() {
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId) return
|
||||
void syncAppearancePrefs(userId)
|
||||
}, [isAuthenticated])
|
||||
|
||||
useEffect(() => {
|
||||
const handleOnline = () => {
|
||||
setOnline(true)
|
||||
@@ -208,6 +229,53 @@ function App() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const clearAuthenticatedAppState = useCallback(() => {
|
||||
setIsAuthenticated(false)
|
||||
setActiveLogbookId(null)
|
||||
setActiveLogbookTitle(null)
|
||||
setShowUserProfile(false)
|
||||
setTourSelectedEntryId(null)
|
||||
setDemoHighlightEntryId(null)
|
||||
}, [])
|
||||
|
||||
/** After PWA/bfcache resume, React state may still say "logged in" while the master key is gone. */
|
||||
const enforceUnlockedSession = useCallback(() => {
|
||||
if (isViewerMode || isDemoMode || isAcceptingInvite) return
|
||||
// Require full local session (incl. userId) so API calls are not left headless.
|
||||
if (isAuthenticated && !hasUnlockedLocalSession()) {
|
||||
clearAuthenticatedAppState()
|
||||
}
|
||||
}, [
|
||||
isAuthenticated,
|
||||
isViewerMode,
|
||||
isDemoMode,
|
||||
isAcceptingInvite,
|
||||
clearAuthenticatedAppState
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
enforceUnlockedSession()
|
||||
}, [enforceUnlockedSession])
|
||||
|
||||
useEffect(() => {
|
||||
const onPageShow = (event: PageTransitionEvent) => {
|
||||
if (event.persisted) {
|
||||
enforceUnlockedSession()
|
||||
}
|
||||
}
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
enforceUnlockedSession()
|
||||
}
|
||||
}
|
||||
window.addEventListener('pageshow', onPageShow)
|
||||
document.addEventListener('visibilitychange', onVisibility)
|
||||
return () => {
|
||||
window.removeEventListener('pageshow', onPageShow)
|
||||
document.removeEventListener('visibilitychange', onVisibility)
|
||||
}
|
||||
}, [enforceUnlockedSession])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
@@ -216,13 +284,12 @@ function App() {
|
||||
const session = await checkServerSession()
|
||||
if (cancelled) return
|
||||
|
||||
if (session.authenticated && session.userId) {
|
||||
localStorage.setItem('active_userid', session.userId)
|
||||
if (session.authenticated) {
|
||||
persistSessionUserId(session.userId)
|
||||
}
|
||||
|
||||
const savedUser = localStorage.getItem('active_username')
|
||||
const key = getActiveMasterKey()
|
||||
if (session.authenticated && savedUser && key) {
|
||||
// Cookie alone is insufficient — need in-memory master key, username, and userId for API.
|
||||
if (session.authenticated && hasUnlockedLocalSession()) {
|
||||
setIsAuthenticated(true)
|
||||
const savedLogbookId = localStorage.getItem('active_logbook_id')
|
||||
const savedLogbookTitle = localStorage.getItem('active_logbook_title')
|
||||
@@ -231,6 +298,7 @@ function App() {
|
||||
setActiveLogbookTitle(savedLogbookTitle)
|
||||
}
|
||||
}
|
||||
// authenticated + crypto but no userId: stay on login (enforceUnlockedSession guards active UI)
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
console.warn('Session restore failed:', err)
|
||||
@@ -241,7 +309,7 @@ function App() {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
}, [clearAuthenticatedAppState])
|
||||
|
||||
useEffect(() => {
|
||||
syncRouteFromLocation()
|
||||
@@ -256,28 +324,66 @@ function App() {
|
||||
setIsAcceptingInvite(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
registerNavigation({
|
||||
setActiveTab,
|
||||
setSelectedEntryId: setTourSelectedEntryId,
|
||||
setFeedbackOpen: setTourFeedbackOpen
|
||||
})
|
||||
}, [registerNavigation])
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && activeLogbookId) {
|
||||
setDemoHighlightEntryId(getStoredDemoFirstEntryId())
|
||||
}
|
||||
}, [isAuthenticated, activeLogbookId])
|
||||
|
||||
const selectLogbook = (id: string, title: string) => {
|
||||
const selectLogbook = useCallback((id: string, title: string) => {
|
||||
setActiveLogbookId(id)
|
||||
setActiveLogbookTitle(title)
|
||||
setActiveTab('logs')
|
||||
setTourSelectedEntryId(null)
|
||||
localStorage.setItem('active_logbook_id', id)
|
||||
localStorage.setItem('active_logbook_title', title)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const ensureTourLogbookOpen = useCallback(async () => {
|
||||
const ctx = await resolveTourLogbookContext(tourLogbookRef.current?.id)
|
||||
if (!ctx) return
|
||||
|
||||
if (activeLogbookRef.current.id !== ctx.logbookId) {
|
||||
selectLogbook(ctx.logbookId, ctx.title)
|
||||
}
|
||||
|
||||
if (ctx.firstEntryId) {
|
||||
setDemoHighlightEntryId(ctx.firstEntryId)
|
||||
registerDemoTourContext({ firstEntryId: ctx.firstEntryId })
|
||||
}
|
||||
}, [registerDemoTourContext, selectLogbook])
|
||||
|
||||
useEffect(() => {
|
||||
registerNavigation({
|
||||
setActiveTab,
|
||||
setSelectedEntryId: setTourSelectedEntryId,
|
||||
setFeedbackOpen: setTourFeedbackOpen,
|
||||
setProfileOpen: setShowUserProfile,
|
||||
ensureLogbookForTour: ensureTourLogbookOpen,
|
||||
setLogbookActive: (active) => {
|
||||
if (active) {
|
||||
void ensureTourLogbookOpen()
|
||||
return
|
||||
}
|
||||
|
||||
const { id, title } = activeLogbookRef.current
|
||||
if (id && title) {
|
||||
tourLogbookRef.current = { id, title }
|
||||
}
|
||||
setActiveLogbookId(null)
|
||||
setActiveLogbookTitle(null)
|
||||
setTourSelectedEntryId(null)
|
||||
localStorage.removeItem('active_logbook_id')
|
||||
localStorage.removeItem('active_logbook_title')
|
||||
}
|
||||
})
|
||||
}, [ensureTourLogbookOpen, registerNavigation])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !activeLogbookId) return
|
||||
void (async () => {
|
||||
const ctx = await resolveTourLogbookContext()
|
||||
if (!ctx || ctx.logbookId !== activeLogbookId) return
|
||||
if (ctx.firstEntryId) {
|
||||
setDemoHighlightEntryId(ctx.firstEntryId)
|
||||
registerDemoTourContext({ firstEntryId: ctx.firstEntryId })
|
||||
}
|
||||
})()
|
||||
}, [isAuthenticated, activeLogbookId, registerDemoTourContext])
|
||||
|
||||
const openLogbookById = useCallback(
|
||||
async (logbookId: string) => {
|
||||
@@ -293,7 +399,7 @@ function App() {
|
||||
}
|
||||
selectLogbook(logbookId, `${logbookId.slice(0, 8)}…`)
|
||||
},
|
||||
[]
|
||||
[selectLogbook]
|
||||
)
|
||||
|
||||
const consumePendingPushLogbook = useCallback(() => {
|
||||
@@ -345,8 +451,20 @@ function App() {
|
||||
const savedLogbookId = localStorage.getItem('active_logbook_id')
|
||||
const savedLogbookTitle = localStorage.getItem('active_logbook_title')
|
||||
if (savedLogbookId && savedLogbookTitle) {
|
||||
setActiveLogbookId(savedLogbookId)
|
||||
setActiveLogbookTitle(savedLogbookTitle)
|
||||
try {
|
||||
const books = await fetchLogbooks()
|
||||
const match = books.find((b) => b.id === savedLogbookId)
|
||||
if (match) {
|
||||
setActiveLogbookId(match.id)
|
||||
setActiveLogbookTitle(match.title)
|
||||
} else {
|
||||
localStorage.removeItem('active_logbook_id')
|
||||
localStorage.removeItem('active_logbook_title')
|
||||
}
|
||||
} catch {
|
||||
setActiveLogbookId(savedLogbookId)
|
||||
setActiveLogbookTitle(savedLogbookTitle)
|
||||
}
|
||||
}
|
||||
consumePendingPushLogbook()
|
||||
}
|
||||
@@ -380,8 +498,7 @@ function App() {
|
||||
}
|
||||
|
||||
const toggleLanguage = () => {
|
||||
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
|
||||
i18n.changeLanguage(nextLang)
|
||||
cycleAppLanguage(i18n)
|
||||
}
|
||||
|
||||
const handleExitDemo = () => {
|
||||
@@ -441,22 +558,27 @@ function App() {
|
||||
const isLogbookOwner =
|
||||
activeAccessRole === 'OWNER' || activeLogbookRecord?.isShared !== 1
|
||||
|
||||
if (showUserProfile) {
|
||||
return (
|
||||
<div style={{ display: 'contents' }}>
|
||||
{pwaInstallBanner}
|
||||
<UserProfilePage
|
||||
onBack={() => setShowUserProfile(false)}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!activeLogbookId) {
|
||||
return (
|
||||
<div style={{ display: 'contents' }}>
|
||||
{pwaInstallBanner}
|
||||
{showUserProfile ? (
|
||||
<UserProfilePage
|
||||
onBack={() => setShowUserProfile(false)}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
) : (
|
||||
<LogbookDashboard
|
||||
onSelectLogbook={selectLogbook}
|
||||
onLogout={handleLogout}
|
||||
onOpenProfile={() => setShowUserProfile(true)}
|
||||
/>
|
||||
)}
|
||||
<LogbookDashboard
|
||||
onSelectLogbook={selectLogbook}
|
||||
onLogout={handleLogout}
|
||||
onOpenProfile={() => setShowUserProfile(true)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -506,6 +628,8 @@ function App() {
|
||||
<Languages size={18} />
|
||||
</button>
|
||||
|
||||
<ProfileHeaderButton onClick={() => setShowUserProfile(true)} />
|
||||
|
||||
<DisclaimerHeaderButton />
|
||||
|
||||
<FeedbackHeaderButton
|
||||
@@ -630,15 +754,17 @@ function App() {
|
||||
|
||||
export default function AppWrapper() {
|
||||
return (
|
||||
<DialogProvider>
|
||||
<UnsavedChangesProvider>
|
||||
<AppTourProvider>
|
||||
<PwaUpdatePrompt />
|
||||
<App />
|
||||
<AppTourOverlay />
|
||||
</AppTourProvider>
|
||||
<AppFooter />
|
||||
</UnsavedChangesProvider>
|
||||
</DialogProvider>
|
||||
<AppErrorBoundary>
|
||||
<DialogProvider>
|
||||
<UnsavedChangesProvider>
|
||||
<AppTourProvider>
|
||||
<PwaUpdatePrompt />
|
||||
<App />
|
||||
<AppTourOverlay />
|
||||
</AppTourProvider>
|
||||
<AppFooter />
|
||||
</UnsavedChangesProvider>
|
||||
</DialogProvider>
|
||||
</AppErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
interface State {
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
export default class AppErrorBoundary extends Component<Props, State> {
|
||||
state: State = { error: null }
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error('Unhandled app error:', error, info.componentStack)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.error) {
|
||||
return this.props.children
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-screen">
|
||||
<div className="auth-card glass" role="alert">
|
||||
<h2 style={{ marginTop: 0 }}>Kapteins Daagbok</h2>
|
||||
<p style={{ color: 'var(--app-text-muted)', lineHeight: 1.5 }}>
|
||||
Die App ist nach dem Neustart in einen fehlerhaften Zustand geraten. Bitte neu laden
|
||||
oder die App vollständig beenden und erneut öffnen.
|
||||
</p>
|
||||
<button type="button" className="btn primary" style={{ width: '100%', marginTop: 16 }} onClick={() => window.location.reload()}>
|
||||
Neu laden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { X, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import {
|
||||
getTourStepCopy,
|
||||
getTourTargetSelector,
|
||||
getTourTargetRetryDelay,
|
||||
isCenteredTourStep,
|
||||
useAppTour
|
||||
} from '../context/AppTourContext.tsx'
|
||||
@@ -17,6 +18,20 @@ interface SpotlightRect {
|
||||
|
||||
const TOOLTIP_EDGE_MARGIN = 16
|
||||
const TOOLTIP_ESTIMATED_HEIGHT = 240
|
||||
const TOOLTIP_WIDTH = 420
|
||||
const TARGET_VIEWPORT_MARGIN = 24
|
||||
|
||||
function clampTooltipTop(preferred: number): number {
|
||||
const maxTop = window.innerHeight - TOOLTIP_EDGE_MARGIN - TOOLTIP_ESTIMATED_HEIGHT
|
||||
return Math.max(TOOLTIP_EDGE_MARGIN, Math.min(preferred, maxTop))
|
||||
}
|
||||
|
||||
function computeTooltipLeft(spotlight: SpotlightRect): number {
|
||||
const tooltipWidth = Math.min(TOOLTIP_WIDTH, window.innerWidth - TOOLTIP_EDGE_MARGIN * 2)
|
||||
const ideal = spotlight.left + spotlight.width / 2 - tooltipWidth / 2
|
||||
const maxLeft = window.innerWidth - TOOLTIP_EDGE_MARGIN - tooltipWidth
|
||||
return Math.max(TOOLTIP_EDGE_MARGIN, Math.min(ideal, maxLeft))
|
||||
}
|
||||
|
||||
function buildCutoutClipPath(rect: SpotlightRect): string {
|
||||
const right = rect.left + rect.width
|
||||
@@ -28,20 +43,36 @@ function computeTooltipTop(spotlight: SpotlightRect): number {
|
||||
const viewportBottom = window.innerHeight - TOOLTIP_EDGE_MARGIN
|
||||
const below = spotlight.top + spotlight.height + 12
|
||||
if (below + TOOLTIP_ESTIMATED_HEIGHT <= viewportBottom) {
|
||||
return below
|
||||
return clampTooltipTop(below)
|
||||
}
|
||||
|
||||
const above = spotlight.top - 12 - TOOLTIP_ESTIMATED_HEIGHT
|
||||
if (above >= TOOLTIP_EDGE_MARGIN) {
|
||||
return above
|
||||
return clampTooltipTop(above)
|
||||
}
|
||||
|
||||
return Math.max(
|
||||
TOOLTIP_EDGE_MARGIN,
|
||||
Math.min(below, viewportBottom - TOOLTIP_ESTIMATED_HEIGHT)
|
||||
return clampTooltipTop(below)
|
||||
}
|
||||
|
||||
function isTargetVisibleInViewport(rect: DOMRect): boolean {
|
||||
return (
|
||||
rect.top >= TARGET_VIEWPORT_MARGIN &&
|
||||
rect.bottom <= window.innerHeight - TARGET_VIEWPORT_MARGIN
|
||||
)
|
||||
}
|
||||
|
||||
function measureSpotlight(el: Element): SpotlightRect | null {
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.width <= 0 || rect.height <= 0) return null
|
||||
const padding = 8
|
||||
return {
|
||||
top: Math.max(8, rect.top - padding),
|
||||
left: Math.max(8, rect.left - padding),
|
||||
width: rect.width + padding * 2,
|
||||
height: rect.height + padding * 2
|
||||
}
|
||||
}
|
||||
|
||||
export default function AppTourOverlay() {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
@@ -50,6 +81,7 @@ export default function AppTourOverlay() {
|
||||
currentStepId,
|
||||
currentStepIndex,
|
||||
totalSteps,
|
||||
layoutTick,
|
||||
nextStep,
|
||||
prevStep,
|
||||
skipTour
|
||||
@@ -65,7 +97,10 @@ export default function AppTourOverlay() {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const updateSpotlight = () => {
|
||||
if (cancelled) return
|
||||
const selector = getTourTargetSelector(currentStepId)
|
||||
if (!selector) {
|
||||
setSpotlight(null)
|
||||
@@ -76,27 +111,38 @@ export default function AppTourOverlay() {
|
||||
setSpotlight(null)
|
||||
return
|
||||
}
|
||||
|
||||
const rect = el.getBoundingClientRect()
|
||||
const padding = 8
|
||||
setSpotlight({
|
||||
top: Math.max(8, rect.top - padding),
|
||||
left: Math.max(8, rect.left - padding),
|
||||
width: rect.width + padding * 2,
|
||||
height: rect.height + padding * 2
|
||||
})
|
||||
if (!isTargetVisibleInViewport(rect)) {
|
||||
el.scrollIntoView({ behavior: 'instant', block: 'center', inline: 'nearest' })
|
||||
window.requestAnimationFrame(() => {
|
||||
if (cancelled) return
|
||||
const next = measureSpotlight(el)
|
||||
setSpotlight(next)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setSpotlight(measureSpotlight(el))
|
||||
}
|
||||
|
||||
updateSpotlight()
|
||||
window.addEventListener('resize', updateSpotlight)
|
||||
window.addEventListener('scroll', updateSpotlight, true)
|
||||
const timer = window.setTimeout(updateSpotlight, 120)
|
||||
|
||||
const retryDelays =
|
||||
currentStepId === 'entry_track'
|
||||
? [400, 700, 1100, 1600]
|
||||
: [getTourTargetRetryDelay(currentStepId), 120, 280, 480]
|
||||
const timers = retryDelays.map((delay) => window.setTimeout(updateSpotlight, delay))
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer)
|
||||
cancelled = true
|
||||
for (const timer of timers) window.clearTimeout(timer)
|
||||
window.removeEventListener('resize', updateSpotlight)
|
||||
window.removeEventListener('scroll', updateSpotlight, true)
|
||||
}
|
||||
}, [currentStepId, isActive])
|
||||
}, [currentStepId, isActive, layoutTick])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return
|
||||
@@ -132,9 +178,17 @@ export default function AppTourOverlay() {
|
||||
const tooltipStyle = centered
|
||||
? undefined
|
||||
: spotlight
|
||||
? { top: computeTooltipTop(spotlight) }
|
||||
? { top: computeTooltipTop(spotlight), left: computeTooltipLeft(spotlight) }
|
||||
: { top: '20%' }
|
||||
|
||||
const tooltipClassName = [
|
||||
'app-tour-tooltip',
|
||||
centered ? 'centered' : '',
|
||||
!centered && spotlight ? 'app-tour-tooltip--anchored' : ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
const backdropStyle = spotlight && !centered
|
||||
? { clipPath: buildCutoutClipPath(spotlight) }
|
||||
: undefined
|
||||
@@ -159,7 +213,7 @@ export default function AppTourOverlay() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={`app-tour-tooltip${centered ? ' centered' : ''}`} style={tooltipStyle}>
|
||||
<div className={tooltipClassName} style={tooltipStyle}>
|
||||
<button type="button" className="app-tour-close" onClick={skipTour} aria-label={t('tour.skip')}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
registerUser,
|
||||
loginUser,
|
||||
completeLoginWithRecovery,
|
||||
setLocalPin,
|
||||
hasLocalPin,
|
||||
decryptWithLocalPin,
|
||||
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
|
||||
import {
|
||||
registerUser,
|
||||
loginUser,
|
||||
completeLoginWithRecovery,
|
||||
setLocalPin,
|
||||
hasLocalPin,
|
||||
decryptWithLocalPin,
|
||||
getActiveMasterKey,
|
||||
getKnownUsernames,
|
||||
forgetUsername
|
||||
forgetUsername,
|
||||
hasUnlockedLocalSession,
|
||||
logoutUser
|
||||
} from '../services/auth.js'
|
||||
import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react'
|
||||
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
|
||||
import DisclaimerModal from './DisclaimerModal.tsx'
|
||||
import BetaBadge from './BetaBadge.tsx'
|
||||
import {
|
||||
isPasskeyCompatibleLocation,
|
||||
localizeWebAuthnError,
|
||||
toPasskeyCompatibleUrl
|
||||
} from '../utils/passkeyHost.ts'
|
||||
|
||||
interface AuthOnboardingProps {
|
||||
onAuthenticated: () => void
|
||||
@@ -50,6 +59,17 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
||||
|
||||
const [isNewRegistration, setIsNewRegistration] = useState(false)
|
||||
const [showDisclaimer, setShowDisclaimer] = useState(false)
|
||||
const [showHelp, setShowHelp] = useState(false)
|
||||
|
||||
const passkeyHostOk = isPasskeyCompatibleLocation()
|
||||
const passkeyCompatibleUrl = passkeyHostOk ? null : toPasskeyCompatibleUrl(window.location.href)
|
||||
|
||||
const formatAuthError = (message: string) =>
|
||||
localizeWebAuthnError(message, {
|
||||
invalidHost: t('auth.error_invalid_host'),
|
||||
cancelled: t('auth.error_passkey_cancelled'),
|
||||
invalidRpId: t('auth.error_invalid_rp_id')
|
||||
})
|
||||
|
||||
const finishAuth = () => {
|
||||
if (isNewRegistration) {
|
||||
@@ -78,7 +98,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
||||
setRecoveryPhrase(result.recoveryPhrase)
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Registration failed')
|
||||
setError(formatAuthError(err.message || 'Registration failed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -118,7 +138,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Login failed')
|
||||
setError(formatAuthError(err.message || 'Login failed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -182,19 +202,33 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
||||
|
||||
const handlePinLoginSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!pinLoginInput.trim()) return
|
||||
if (!pinLoginInput.trim() || loading) return
|
||||
|
||||
const resolvedUser =
|
||||
username.trim() ||
|
||||
encryptedPayloads?.username ||
|
||||
localStorage.getItem('active_username') ||
|
||||
''
|
||||
if (!resolvedUser) {
|
||||
setError(t('auth.error_session_incomplete'))
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const resolvedUser = username.trim() || encryptedPayloads?.username
|
||||
const key = await decryptWithLocalPin(pinLoginInput.trim(), resolvedUser)
|
||||
if (key) {
|
||||
onAuthenticated()
|
||||
} else {
|
||||
if (!key) {
|
||||
setError(t('auth.error_incorrect_pin'))
|
||||
return
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (!hasUnlockedLocalSession()) {
|
||||
setError(t('auth.error_session_incomplete'))
|
||||
return
|
||||
}
|
||||
setShowPinLogin(false)
|
||||
onAuthenticated()
|
||||
} catch {
|
||||
setError(t('auth.error_incorrect_pin'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
@@ -207,8 +241,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
||||
}
|
||||
|
||||
const toggleLanguage = () => {
|
||||
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
|
||||
i18n.changeLanguage(nextLang)
|
||||
cycleAppLanguage(i18n)
|
||||
}
|
||||
|
||||
const copyToClipboard = () => {
|
||||
@@ -359,6 +392,24 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
||||
>
|
||||
{t('auth.use_recovery_instead')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
setShowPinLogin(false)
|
||||
setPinLoginInput('')
|
||||
setEncryptedPayloads(null)
|
||||
setError(null)
|
||||
await logoutUser()
|
||||
})()
|
||||
}}
|
||||
disabled={loading}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{t('auth.back')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -377,16 +428,37 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
||||
{t('auth.recovery_fallback_warning')}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleRecoverySubmit} className="auth-form">
|
||||
<textarea
|
||||
className="input-textarea"
|
||||
placeholder={t('auth.recovery_placeholder')}
|
||||
value={recoveryInput}
|
||||
onChange={(e) => setRecoveryInput(e.target.value)}
|
||||
disabled={loading}
|
||||
rows={3}
|
||||
required
|
||||
/>
|
||||
<form onSubmit={handleRecoverySubmit} className="auth-form" autoComplete="on">
|
||||
{(username.trim() || encryptedPayloads?.username) && (
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
autoComplete="username"
|
||||
value={username.trim() || encryptedPayloads?.username || ''}
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
style={{ position: 'absolute', width: 0, height: 0, opacity: 0, pointerEvents: 'none' }}
|
||||
/>
|
||||
)}
|
||||
<div className="input-group">
|
||||
<label htmlFor="recovery-key" className="input-label" style={{ display: 'block', marginBottom: '8px', color: '#94a3b8' }}>
|
||||
{t('auth.enter_recovery')}
|
||||
</label>
|
||||
<input
|
||||
id="recovery-key"
|
||||
name="recovery-key"
|
||||
type="password"
|
||||
className="input-text"
|
||||
placeholder={t('auth.recovery_placeholder')}
|
||||
value={recoveryInput}
|
||||
onChange={(e) => setRecoveryInput(e.target.value)}
|
||||
disabled={loading}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <div className="auth-error">{error}</div>}
|
||||
|
||||
@@ -410,6 +482,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
||||
|
||||
// Render 3: Standard Login / Registration options form
|
||||
return (
|
||||
<>
|
||||
<div className="auth-card glass">
|
||||
<div className="auth-brand">
|
||||
<img src="/logo.png" alt="Kapteins Daagbok" className="auth-logo-img" />
|
||||
@@ -421,12 +494,21 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
||||
</div>
|
||||
|
||||
<div className="auth-form" style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||
{!passkeyHostOk && passkeyCompatibleUrl && (
|
||||
<div className="auth-error" role="alert">
|
||||
<p style={{ margin: '0 0 8px' }}>{t('auth.error_invalid_host')}</p>
|
||||
<a href={passkeyCompatibleUrl} className="btn secondary" style={{ display: 'inline-block', textDecoration: 'none' }}>
|
||||
{t('auth.use_localhost_link')}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Prominent Login button */}
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
onClick={() => handleLogin()}
|
||||
disabled={loading}
|
||||
disabled={loading || !passkeyHostOk}
|
||||
style={{ width: '100%', padding: '16px' }}
|
||||
>
|
||||
{loading
|
||||
@@ -559,7 +641,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
||||
<button
|
||||
type="submit"
|
||||
className="btn secondary"
|
||||
disabled={loading || !username.trim()}
|
||||
disabled={loading || !username.trim() || !passkeyHostOk}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{t('auth.register')}
|
||||
@@ -570,15 +652,23 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
||||
</div>
|
||||
|
||||
<div className="auth-footer">
|
||||
<button className="btn-icon-text" onClick={toggleLanguage}>
|
||||
<button type="button" className="btn-icon-text" onClick={toggleLanguage}>
|
||||
<Languages size={18} />
|
||||
{i18n.language.startsWith('de') ? 'English' : 'Deutsch'}
|
||||
{t(`languages.${getNextLanguage(i18n.language)}`)}
|
||||
</button>
|
||||
<a href="#help" className="btn-icon-text link-sec">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon-text link-sec"
|
||||
onClick={() => setShowHelp(true)}
|
||||
title={t('disclaimer.button_title')}
|
||||
aria-label={t('disclaimer.button_title')}
|
||||
>
|
||||
<HelpCircle size={18} />
|
||||
{t('auth.help')}
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<DisclaimerModal open={showHelp} onClose={() => setShowHelp(false)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import { useCallback, useId, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
type CourseOutputMode,
|
||||
type CourseStep,
|
||||
dialDegreesToStorageValue,
|
||||
formatCourseAngle,
|
||||
formatCourseDisplay,
|
||||
isCardinalDirection,
|
||||
loadCourseDialStep,
|
||||
parseCourseAngle,
|
||||
pointerAngleToDegrees,
|
||||
resolveCourseOutputMode,
|
||||
saveCourseDialStep,
|
||||
snapDegrees,
|
||||
valueToDialDegrees
|
||||
} from '../utils/courseAngle.js'
|
||||
|
||||
interface CourseDialInputProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
disabled?: boolean
|
||||
step?: CourseStep
|
||||
allowCardinal?: boolean
|
||||
displayMode?: 'degrees' | 'cardinal' | 'auto'
|
||||
size?: 'md' | 'sm'
|
||||
'aria-label': string
|
||||
id?: string
|
||||
}
|
||||
|
||||
const TICK_DEGREES = [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330]
|
||||
|
||||
function polarPoint(degrees: number, radius: number): { x: number; y: number } {
|
||||
const rad = (degrees * Math.PI) / 180
|
||||
return {
|
||||
x: 100 + Math.sin(rad) * radius,
|
||||
y: 100 - Math.cos(rad) * radius
|
||||
}
|
||||
}
|
||||
|
||||
export default function CourseDialInput({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
step: stepProp,
|
||||
allowCardinal = false,
|
||||
displayMode = 'degrees',
|
||||
size = 'md',
|
||||
'aria-label': ariaLabel,
|
||||
id: idProp
|
||||
}: CourseDialInputProps) {
|
||||
const { t } = useTranslation()
|
||||
const generatedId = useId()
|
||||
const inputId = idProp ?? `${generatedId}-input`
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const [step, setStep] = useState<CourseStep>(() => stepProp ?? loadCourseDialStep())
|
||||
const [inputDraft, setInputDraft] = useState<string | null>(null)
|
||||
const [inputError, setInputError] = useState<string | null>(null)
|
||||
const [outputModeOverride, setOutputModeOverride] = useState<CourseOutputMode | null>(null)
|
||||
|
||||
const effectiveStep = stepProp ?? step
|
||||
const outputMode =
|
||||
outputModeOverride ??
|
||||
resolveCourseOutputMode(value, displayMode, allowCardinal)
|
||||
|
||||
const dialDegrees = useMemo(
|
||||
() => snapDegrees(valueToDialDegrees(value, allowCardinal), effectiveStep),
|
||||
[value, allowCardinal, effectiveStep]
|
||||
)
|
||||
|
||||
const centerLabel = useMemo(
|
||||
() => formatCourseDisplay(value, allowCardinal),
|
||||
[value, allowCardinal]
|
||||
)
|
||||
|
||||
const tickLabel = useCallback(
|
||||
(degrees: number) => {
|
||||
if (degrees === 0) return t('logs.compass_n')
|
||||
if (degrees === 90) return t('logs.compass_e')
|
||||
if (degrees === 180) return t('logs.compass_s')
|
||||
if (degrees === 270) return t('logs.compass_w')
|
||||
return String(degrees).padStart(3, '0')
|
||||
},
|
||||
[t]
|
||||
)
|
||||
|
||||
const applyDegrees = useCallback(
|
||||
(degrees: number) => {
|
||||
onChange(dialDegreesToStorageValue(degrees, outputMode, effectiveStep))
|
||||
setInputDraft(null)
|
||||
setInputError(null)
|
||||
},
|
||||
[onChange, outputMode, effectiveStep]
|
||||
)
|
||||
|
||||
const updateFromPointer = useCallback(
|
||||
(clientX: number, clientY: number) => {
|
||||
const svg = svgRef.current
|
||||
if (!svg || disabled) return
|
||||
const rect = svg.getBoundingClientRect()
|
||||
const cx = rect.left + rect.width / 2
|
||||
const cy = rect.top + rect.height / 2
|
||||
const raw = pointerAngleToDegrees(clientX, clientY, cx, cy)
|
||||
applyDegrees(raw)
|
||||
},
|
||||
[applyDegrees, disabled]
|
||||
)
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent<SVGSVGElement>) => {
|
||||
if (disabled) return
|
||||
e.preventDefault()
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
updateFromPointer(e.clientX, e.clientY)
|
||||
}
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent<SVGSVGElement>) => {
|
||||
if (disabled || !e.currentTarget.hasPointerCapture(e.pointerId)) return
|
||||
updateFromPointer(e.clientX, e.clientY)
|
||||
}
|
||||
|
||||
const handlePointerUp = (e: React.PointerEvent<SVGSVGElement>) => {
|
||||
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
||||
e.currentTarget.releasePointerCapture(e.pointerId)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputDraft(e.target.value)
|
||||
}
|
||||
|
||||
const commitInput = () => {
|
||||
const draft = (inputDraft ?? value).trim()
|
||||
setInputDraft(null)
|
||||
if (!draft) {
|
||||
onChange('')
|
||||
setInputError(null)
|
||||
return
|
||||
}
|
||||
if (allowCardinal && outputMode === 'cardinal' && isCardinalDirection(draft)) {
|
||||
onChange(draft.toUpperCase())
|
||||
setInputError(null)
|
||||
return
|
||||
}
|
||||
const parsed = parseCourseAngle(draft)
|
||||
if (parsed === null) {
|
||||
setInputError(t('logs.course_invalid'))
|
||||
return
|
||||
}
|
||||
onChange(formatCourseAngle(snapDegrees(parsed, effectiveStep)))
|
||||
setInputError(null)
|
||||
}
|
||||
|
||||
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
commitInput()
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
const base = parseCourseAngle(value) ?? dialDegrees
|
||||
const delta = e.key === 'ArrowUp' ? effectiveStep : -effectiveStep
|
||||
applyDegrees(base + delta)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStepChange = (next: CourseStep) => {
|
||||
if (stepProp !== undefined) return
|
||||
setStep(next)
|
||||
saveCourseDialStep(next)
|
||||
const parsed = parseCourseAngle(value)
|
||||
if (parsed !== null) {
|
||||
onChange(formatCourseAngle(snapDegrees(parsed, next)))
|
||||
}
|
||||
}
|
||||
|
||||
const toggleOutputMode = () => {
|
||||
const next: CourseOutputMode = outputMode === 'cardinal' ? 'degrees' : 'cardinal'
|
||||
setOutputModeOverride(next)
|
||||
const deg = valueToDialDegrees(value, allowCardinal)
|
||||
onChange(dialDegreesToStorageValue(deg, next, effectiveStep))
|
||||
}
|
||||
|
||||
const inputValue = inputDraft ?? value
|
||||
const sliderNow = dialDegrees
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`course-dial course-dial--${size}${disabled ? ' course-dial--disabled' : ''}`}
|
||||
>
|
||||
{!stepProp && (
|
||||
<div className="course-dial__step-toolbar" role="group" aria-label={t('logs.course_dial_step_label')}>
|
||||
{([1, 5, 10] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
className={`course-dial__step-btn${effectiveStep === s ? ' is-active' : ''}`}
|
||||
onClick={() => handleStepChange(s)}
|
||||
disabled={disabled}
|
||||
aria-pressed={effectiveStep === s}
|
||||
>
|
||||
{s === 1 ? t('logs.course_step_fine') : s === 5 ? t('logs.course_step_medium') : t('logs.course_step_coarse')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="course-dial__ring-wrap"
|
||||
role="slider"
|
||||
aria-label={ariaLabel}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={360}
|
||||
aria-valuenow={sliderNow}
|
||||
aria-disabled={disabled}
|
||||
>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
className="course-dial__svg"
|
||||
viewBox="0 0 200 200"
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
>
|
||||
<circle className="course-dial__track" cx="100" cy="100" r="88" />
|
||||
{TICK_DEGREES.map((deg) => {
|
||||
const inner = polarPoint(deg, 76)
|
||||
const outer = polarPoint(deg, 88)
|
||||
const label = polarPoint(deg, 64)
|
||||
return (
|
||||
<g key={deg}>
|
||||
<line className="course-dial__tick" x1={inner.x} y1={inner.y} x2={outer.x} y2={outer.y} />
|
||||
<text className="course-dial__label" x={label.x} y={label.y} textAnchor="middle" dominantBaseline="middle">
|
||||
{tickLabel(deg)}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
<g className="course-dial__needle" transform={`rotate(${dialDegrees} 100 100)`}>
|
||||
<line x1="100" y1="100" x2="100" y2="28" />
|
||||
<circle cx="100" cy="100" r="6" />
|
||||
</g>
|
||||
<text className="course-dial__center" x="100" y="100" textAnchor="middle" dominantBaseline="middle">
|
||||
{centerLabel}
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<p className="course-dial__hint">{t('logs.course_dial_hint')}</p>
|
||||
|
||||
<input
|
||||
id={inputId}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
className="input-text course-dial__input"
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onBlur={commitInput}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
disabled={disabled}
|
||||
placeholder={
|
||||
outputMode === 'cardinal'
|
||||
? t('logs.course_placeholder_cardinal')
|
||||
: t('logs.course_placeholder_degrees')
|
||||
}
|
||||
aria-label={ariaLabel}
|
||||
aria-invalid={inputError ? true : undefined}
|
||||
/>
|
||||
|
||||
{inputError && <p className="course-dial__error">{inputError}</p>}
|
||||
|
||||
{allowCardinal && displayMode === 'auto' && (
|
||||
<button
|
||||
type="button"
|
||||
className="course-dial__mode-toggle"
|
||||
onClick={toggleOutputMode}
|
||||
disabled={disabled}
|
||||
>
|
||||
{outputMode === 'cardinal' ? t('logs.wind_mode_degrees') : t('logs.wind_mode_cardinal')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
|
||||
import VesselForm from './VesselForm.tsx'
|
||||
import CrewForm from './CrewForm.tsx'
|
||||
import LogEntriesList from './LogEntriesList.tsx'
|
||||
@@ -31,7 +32,9 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
||||
registerNavigation({
|
||||
setActiveTab,
|
||||
setSelectedEntryId: setTourSelectedEntryId,
|
||||
setFeedbackOpen: () => {}
|
||||
setFeedbackOpen: () => {},
|
||||
setLogbookActive: () => {},
|
||||
setProfileOpen: () => {}
|
||||
})
|
||||
registerDemoTourContext({ firstEntryId: fixture.firstEntryId })
|
||||
|
||||
@@ -46,8 +49,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
||||
}, [registerNavigation, registerDemoTourContext, startTour, fixture.firstEntryId])
|
||||
|
||||
const toggleLanguage = () => {
|
||||
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
|
||||
i18n.changeLanguage(nextLang)
|
||||
cycleAppLanguage(i18n)
|
||||
}
|
||||
|
||||
const { title, yacht, crews, entries, gpsTracks, photos, firstEntryId } = fixture
|
||||
@@ -85,7 +87,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
||||
</button>
|
||||
<button className="btn secondary" onClick={toggleLanguage} style={{ width: 'auto', padding: '6px 12px', fontSize: '13px' }}>
|
||||
<Globe size={14} style={{ marginRight: '4px' }} />
|
||||
{i18n.language.startsWith('de') ? 'English' : 'Deutsch'}
|
||||
{t(`languages.${getNextLanguage(i18n.language)}`)}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useId, useMemo } from 'react'
|
||||
import { joinTimeHHMM, splitTimeHHMM } from '../utils/logEntryPayload.js'
|
||||
|
||||
const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'))
|
||||
const MINUTES = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'))
|
||||
|
||||
interface EventTimeInput24hProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
disabled?: boolean
|
||||
'aria-label'?: string
|
||||
}
|
||||
|
||||
export default function EventTimeInput24h({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
'aria-label': ariaLabel
|
||||
}: EventTimeInput24hProps) {
|
||||
const baseId = useId()
|
||||
const { hours, minutes } = useMemo(() => splitTimeHHMM(value), [value])
|
||||
|
||||
return (
|
||||
<div className="time-input-24h">
|
||||
<select
|
||||
id={`${baseId}-hours`}
|
||||
className="input-text time-input-24h__select"
|
||||
value={hours}
|
||||
onChange={(e) => onChange(joinTimeHHMM(e.target.value, minutes))}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel ? `${ariaLabel} (h)` : undefined}
|
||||
>
|
||||
{HOURS.map((hour) => (
|
||||
<option key={hour} value={hour}>
|
||||
{hour}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="time-input-24h__sep" aria-hidden="true">
|
||||
:
|
||||
</span>
|
||||
<select
|
||||
id={`${baseId}-minutes`}
|
||||
className="input-text time-input-24h__select"
|
||||
value={minutes}
|
||||
onChange={(e) => onChange(joinTimeHHMM(hours, e.target.value))}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel ? `${ariaLabel} (min)` : undefined}
|
||||
>
|
||||
{MINUTES.map((minute) => (
|
||||
<option key={minute} value={minute}>
|
||||
{minute}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -172,6 +172,7 @@ export default function FeedbackModal({
|
||||
<option value="general">{t('feedback.category_general')}</option>
|
||||
<option value="bug">{t('feedback.category_bug')}</option>
|
||||
<option value="feature">{t('feedback.category_feature')}</option>
|
||||
<option value="translation">{t('feedback.category_translation')}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
|
||||
import { Ship, LogIn, UserPlus, AlertTriangle, ShieldCheck, Languages, ArrowRight, KeyRound } from 'lucide-react'
|
||||
import {
|
||||
getActiveMasterKey,
|
||||
@@ -308,7 +309,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
||||
}
|
||||
|
||||
const toggleLanguage = () => {
|
||||
i18n.changeLanguage(i18n.language.startsWith('de') ? 'en' : 'de')
|
||||
cycleAppLanguage(i18n)
|
||||
}
|
||||
|
||||
if (recoveryPhrase) {
|
||||
@@ -344,15 +345,36 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
||||
<h2>{t('auth.enter_recovery')}</h2>
|
||||
</div>
|
||||
<p className="recovery-warning">{t('auth.recovery_fallback_warning')}</p>
|
||||
<form onSubmit={handleRecoverySubmit}>
|
||||
<textarea
|
||||
className="input-text"
|
||||
placeholder={t('auth.recovery_placeholder')}
|
||||
value={recoveryInput}
|
||||
onChange={(e) => setRecoveryInput(e.target.value)}
|
||||
rows={3}
|
||||
required
|
||||
/>
|
||||
<form onSubmit={handleRecoverySubmit} autoComplete="on">
|
||||
{(username.trim() || encryptedPayloads?.username) && (
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
autoComplete="username"
|
||||
value={username.trim() || encryptedPayloads?.username || ''}
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
style={{ position: 'absolute', width: 0, height: 0, opacity: 0, pointerEvents: 'none' }}
|
||||
/>
|
||||
)}
|
||||
<div className="input-group">
|
||||
<label htmlFor="invitation-recovery-key" className="input-label" style={{ display: 'block', marginBottom: '8px', color: '#94a3b8' }}>
|
||||
{t('auth.enter_recovery')}
|
||||
</label>
|
||||
<input
|
||||
id="invitation-recovery-key"
|
||||
name="recovery-key"
|
||||
type="password"
|
||||
className="input-text"
|
||||
placeholder={t('auth.recovery_placeholder')}
|
||||
value={recoveryInput}
|
||||
onChange={(e) => setRecoveryInput(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="auth-actions mt-4">
|
||||
<button type="button" className="btn secondary" onClick={() => setShowRecoveryFallback(false)}>
|
||||
{t('auth.back')}
|
||||
@@ -490,7 +512,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
||||
<div className="auth-footer" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '16px', marginTop: '24px' }}>
|
||||
<button className="btn-icon-text" onClick={toggleLanguage}>
|
||||
<Languages size={18} />
|
||||
{i18n.language.startsWith('de') ? t('invitation.switch_language_en') : t('invitation.switch_language_de')}
|
||||
{t(`languages.${getNextLanguage(i18n.language)}`)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import QRCode from 'qrcode'
|
||||
|
||||
interface LinkQrCodeProps {
|
||||
value: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
export default function LinkQrCode({ value, size = 200 }: LinkQrCodeProps) {
|
||||
const { t } = useTranslation()
|
||||
const [dataUrl, setDataUrl] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!value.trim()) {
|
||||
setDataUrl(null)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
void QRCode.toDataURL(value, {
|
||||
width: size,
|
||||
margin: 2,
|
||||
errorCorrectionLevel: 'M',
|
||||
color: { dark: '#0f172a', light: '#ffffff' }
|
||||
})
|
||||
.then((url) => {
|
||||
if (!cancelled) setDataUrl(url)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('QR code generation failed:', err)
|
||||
if (!cancelled) setDataUrl(null)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [value, size])
|
||||
|
||||
if (!value.trim() || !dataUrl) return null
|
||||
|
||||
return (
|
||||
<div className="link-qr-block">
|
||||
<p className="link-qr-label">{t('settings.link_qr_hint')}</p>
|
||||
<img
|
||||
src={dataUrl}
|
||||
width={size}
|
||||
height={size}
|
||||
className="link-qr-image"
|
||||
alt={t('settings.link_qr_alt')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Camera, X } from 'lucide-react'
|
||||
import {
|
||||
captureVideoFrame,
|
||||
preferNativeCameraPicker
|
||||
} from '../utils/captureVideoFrame.js'
|
||||
|
||||
interface LiveCameraCaptureProps {
|
||||
open: boolean
|
||||
busy?: boolean
|
||||
caption?: string
|
||||
onCaptionChange?: (value: string) => void
|
||||
onClose: () => void
|
||||
onCapture: (blob: Blob) => void
|
||||
}
|
||||
|
||||
type Phase = 'live' | 'preview' | 'native'
|
||||
|
||||
export default function LiveCameraCapture({
|
||||
open,
|
||||
busy = false,
|
||||
caption = '',
|
||||
onCaptionChange,
|
||||
onClose,
|
||||
onCapture
|
||||
}: LiveCameraCaptureProps) {
|
||||
const { t } = useTranslation()
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const streamRef = useRef<MediaStream | null>(null)
|
||||
const previewUrlRef = useRef<string | null>(null)
|
||||
|
||||
const [cameraError, setCameraError] = useState<string | null>(null)
|
||||
const [ready, setReady] = useState(false)
|
||||
const [capturing, setCapturing] = useState(false)
|
||||
const [phase, setPhase] = useState<Phase>(() => (preferNativeCameraPicker() ? 'native' : 'live'))
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||
const [previewBlob, setPreviewBlob] = useState<Blob | null>(null)
|
||||
const [streamGeneration, setStreamGeneration] = useState(0)
|
||||
|
||||
const clearPreview = useCallback(() => {
|
||||
if (previewUrlRef.current) {
|
||||
URL.revokeObjectURL(previewUrlRef.current)
|
||||
previewUrlRef.current = null
|
||||
}
|
||||
setPreviewUrl(null)
|
||||
setPreviewBlob(null)
|
||||
}, [])
|
||||
|
||||
const stopStream = useCallback(() => {
|
||||
for (const track of streamRef.current?.getTracks() ?? []) {
|
||||
track.stop()
|
||||
}
|
||||
streamRef.current = null
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = null
|
||||
}
|
||||
setReady(false)
|
||||
}, [])
|
||||
|
||||
const enterPreview = useCallback((blob: Blob) => {
|
||||
stopStream()
|
||||
clearPreview()
|
||||
const url = URL.createObjectURL(blob)
|
||||
previewUrlRef.current = url
|
||||
setPreviewBlob(blob)
|
||||
setPreviewUrl(url)
|
||||
setPhase('preview')
|
||||
}, [stopStream, clearPreview])
|
||||
|
||||
const resetToLive = useCallback(() => {
|
||||
clearPreview()
|
||||
setCameraError(null)
|
||||
setCapturing(false)
|
||||
if (preferNativeCameraPicker()) {
|
||||
setPhase('native')
|
||||
} else {
|
||||
setPhase('live')
|
||||
setStreamGeneration((n) => n + 1)
|
||||
}
|
||||
}, [clearPreview])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
stopStream()
|
||||
clearPreview()
|
||||
setCameraError(null)
|
||||
setCapturing(false)
|
||||
setPhase(preferNativeCameraPicker() ? 'native' : 'live')
|
||||
return
|
||||
}
|
||||
setPhase(preferNativeCameraPicker() ? 'native' : 'live')
|
||||
clearPreview()
|
||||
}, [open, stopStream, clearPreview])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || phase !== 'live') {
|
||||
stopStream()
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const start = async () => {
|
||||
setCameraError(null)
|
||||
setReady(false)
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
setCameraError(t('logs.live_photo_camera_unavailable'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
facingMode: { ideal: 'environment' },
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 }
|
||||
},
|
||||
audio: false
|
||||
})
|
||||
if (cancelled) {
|
||||
for (const track of stream.getTracks()) track.stop()
|
||||
return
|
||||
}
|
||||
streamRef.current = stream
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
|
||||
const markReady = () => {
|
||||
if (cancelled) return
|
||||
if (video.videoWidth > 0 && video.videoHeight > 0) {
|
||||
setReady(true)
|
||||
}
|
||||
}
|
||||
|
||||
video.onloadedmetadata = markReady
|
||||
video.srcObject = stream
|
||||
await video.play()
|
||||
markReady()
|
||||
} catch (err) {
|
||||
console.error('Camera access failed:', err)
|
||||
if (!cancelled) {
|
||||
setCameraError(t('logs.live_photo_camera_denied'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void start()
|
||||
return () => {
|
||||
cancelled = true
|
||||
stopStream()
|
||||
}
|
||||
}, [open, phase, streamGeneration, stopStream, t])
|
||||
|
||||
const handleCapture = async () => {
|
||||
const video = videoRef.current
|
||||
if (!video || !ready || busy || capturing) return
|
||||
|
||||
setCapturing(true)
|
||||
setCameraError(null)
|
||||
try {
|
||||
const blob = await captureVideoFrame(video)
|
||||
enterPreview(blob)
|
||||
} catch (err) {
|
||||
console.error('Live camera capture failed:', err)
|
||||
setCameraError(t('logs.live_photo_capture_failed'))
|
||||
} finally {
|
||||
setCapturing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNativeFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
if (!file || busy) return
|
||||
|
||||
setCameraError(null)
|
||||
try {
|
||||
enterPreview(file)
|
||||
} catch (err) {
|
||||
console.error('Live camera file pick failed:', err)
|
||||
setCameraError(t('logs.live_photo_capture_failed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
if (!previewBlob || busy) return
|
||||
onCapture(previewBlob)
|
||||
}
|
||||
|
||||
const handleRetake = () => {
|
||||
if (busy) return
|
||||
resetToLive()
|
||||
}
|
||||
|
||||
const openNativePicker = () => {
|
||||
if (busy) return
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const showPreview = phase === 'preview' && previewUrl
|
||||
|
||||
return (
|
||||
<div
|
||||
className="live-log-modal-backdrop live-camera-backdrop"
|
||||
onClick={(e) => { if (e.target === e.currentTarget && !busy) onClose() }}
|
||||
>
|
||||
<div className="live-log-modal live-camera-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="live-camera-header">
|
||||
<h3>{t('logs.live_photo_btn')}</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary live-camera-close"
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
aria-label={t('logs.confirm_no')}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="live-camera-file-input"
|
||||
onChange={(e) => void handleNativeFile(e)}
|
||||
/>
|
||||
|
||||
{cameraError && (
|
||||
<p className="live-log-modal-hint auth-error">{cameraError}</p>
|
||||
)}
|
||||
|
||||
{showPreview ? (
|
||||
<div className="live-camera-preview-wrap">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt=""
|
||||
className="live-camera-preview live-camera-preview-still"
|
||||
/>
|
||||
</div>
|
||||
) : phase === 'native' ? (
|
||||
<div className="live-camera-native-prompt">
|
||||
<p className="live-log-modal-hint">{t('logs.live_photo_native_hint')}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary live-camera-open-native"
|
||||
onClick={openNativePicker}
|
||||
disabled={busy}
|
||||
>
|
||||
<Camera size={18} />
|
||||
{t('logs.live_photo_open_camera_btn')}
|
||||
</button>
|
||||
</div>
|
||||
) : cameraError && !ready ? null : (
|
||||
<div className="live-camera-preview-wrap">
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="live-camera-preview"
|
||||
playsInline
|
||||
muted
|
||||
autoPlay
|
||||
/>
|
||||
{!ready && (
|
||||
<p className="live-camera-loading">{t('logs.live_photo_camera_starting')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onCaptionChange && (
|
||||
<div className="input-group live-camera-caption">
|
||||
<label>{t('logs.photo_caption_label')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
placeholder={t('logs.photo_caption_placeholder')}
|
||||
value={caption}
|
||||
onChange={(e) => onCaptionChange(e.target.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="live-log-modal-actions live-camera-actions">
|
||||
<button type="button" className="btn secondary" onClick={onClose} disabled={busy}>
|
||||
{t('logs.confirm_no')}
|
||||
</button>
|
||||
|
||||
{showPreview ? (
|
||||
<>
|
||||
<button type="button" className="btn secondary" onClick={handleRetake} disabled={busy}>
|
||||
{t('logs.live_photo_retake_btn')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary live-camera-shutter"
|
||||
onClick={handleSave}
|
||||
disabled={busy || !previewBlob}
|
||||
>
|
||||
<Camera size={18} />
|
||||
{busy ? t('logs.photo_processing') : t('logs.live_photo_save_btn')}
|
||||
</button>
|
||||
</>
|
||||
) : phase === 'native' ? null : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary live-camera-shutter"
|
||||
onClick={() => void handleCapture()}
|
||||
disabled={busy || capturing || !ready || !!cameraError}
|
||||
>
|
||||
<Camera size={18} />
|
||||
{capturing ? t('logs.photo_processing') : t('logs.live_photo_capture_btn')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,10 +9,11 @@ import { downloadCsv, shareCsv } from '../services/csvExport.js'
|
||||
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import LogEntryEditor from './LogEntryEditor.tsx'
|
||||
import LiveLogView from './LiveLogView.tsx'
|
||||
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import { getSkipperSignStatus, type SkipperSignStatus } from '../utils/signatures.js'
|
||||
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2 } from 'lucide-react'
|
||||
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2, Radio, List } from 'lucide-react'
|
||||
import {
|
||||
carryOverFromPreviousDay,
|
||||
compareTravelDaysChronological,
|
||||
@@ -36,6 +37,8 @@ interface LogEntriesListProps {
|
||||
highlightEntryId?: string | null
|
||||
}
|
||||
|
||||
type LogsViewMode = 'list' | 'live'
|
||||
|
||||
interface DecryptedEntryItem {
|
||||
id: string
|
||||
date: string
|
||||
@@ -75,6 +78,8 @@ export default function LogEntriesList({
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [viewMode, setViewMode] = useState<LogsViewMode>('list')
|
||||
const [returnToLiveAfterEditor, setReturnToLiveAfterEditor] = useState(false)
|
||||
const prevSelectedEntryIdRef = useRef<string | null | undefined>(undefined)
|
||||
|
||||
const loadEntries = useCallback(async () => {
|
||||
@@ -144,17 +149,19 @@ export default function LogEntriesList({
|
||||
}, [logbookId, readOnly, preloadedEntries])
|
||||
|
||||
useEffect(() => {
|
||||
if (viewMode === 'live') return
|
||||
loadEntries()
|
||||
}, [loadEntries])
|
||||
}, [loadEntries, viewMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (viewMode === 'live') return
|
||||
const prevSelectedEntryId = prevSelectedEntryIdRef.current
|
||||
prevSelectedEntryIdRef.current = selectedEntryId
|
||||
|
||||
if (prevSelectedEntryId !== undefined && prevSelectedEntryId !== null && selectedEntryId === null) {
|
||||
loadEntries()
|
||||
}
|
||||
}, [selectedEntryId, loadEntries])
|
||||
}, [selectedEntryId, loadEntries, viewMode])
|
||||
|
||||
const handleDownloadCsv = async () => {
|
||||
setExporting(true)
|
||||
@@ -241,14 +248,15 @@ export default function LogEntriesList({
|
||||
|
||||
decryptedEntries.sort(compareTravelDaysChronological)
|
||||
const previousEntry = decryptedEntries.at(-1) ?? null
|
||||
let { freshwater, fuel, departure } = carryOverFromPreviousDay(previousEntry)
|
||||
let { freshwater, fuel, greywaterLevel, departure } = carryOverFromPreviousDay(previousEntry)
|
||||
|
||||
if (previousEntry && hasCarryOverFromPreviousDay({ freshwater, fuel, departure })) {
|
||||
if (previousEntry && hasCarryOverFromPreviousDay({ freshwater, fuel, greywaterLevel, departure })) {
|
||||
const confirmed = await showConfirm(
|
||||
t('logs.carry_over_tanks_confirm', {
|
||||
departure: departure || '—',
|
||||
fw: formatTankLiters(freshwater.morning),
|
||||
fuel: formatTankLiters(fuel.morning)
|
||||
fuel: formatTankLiters(fuel.morning),
|
||||
greywater: formatTankLiters(greywaterLevel)
|
||||
}),
|
||||
t('logs.carry_over_tanks_title'),
|
||||
t('logs.carry_over_tanks_yes'),
|
||||
@@ -257,6 +265,7 @@ export default function LogEntriesList({
|
||||
if (!confirmed) {
|
||||
freshwater = emptyTankLevels()
|
||||
fuel = emptyTankLevels()
|
||||
greywaterLevel = 0
|
||||
departure = ''
|
||||
}
|
||||
}
|
||||
@@ -274,6 +283,7 @@ export default function LogEntriesList({
|
||||
destination: '',
|
||||
freshwater,
|
||||
fuel,
|
||||
...(greywaterLevel > 0 ? { greywater: { level: greywaterLevel } } : {}),
|
||||
signSkipper: '',
|
||||
signCrew: '',
|
||||
events: []
|
||||
@@ -347,7 +357,13 @@ export default function LogEntriesList({
|
||||
<LogEntryEditor
|
||||
entryId={selectedEntryId}
|
||||
logbookId={logbookId}
|
||||
onBack={() => setSelectedEntryId(null)}
|
||||
onBack={() => {
|
||||
setSelectedEntryId(null)
|
||||
if (returnToLiveAfterEditor) {
|
||||
setViewMode('live')
|
||||
setReturnToLiveAfterEditor(false)
|
||||
}
|
||||
}}
|
||||
readOnly={readOnly}
|
||||
preloadedEntry={preloadedEntries?.find(entry => (entry.payloadId || entry.id) === selectedEntryId)}
|
||||
preloadedPhotos={preloadedPhotos}
|
||||
@@ -356,6 +372,19 @@ export default function LogEntriesList({
|
||||
)
|
||||
}
|
||||
|
||||
if (viewMode === 'live' && !readOnly) {
|
||||
return (
|
||||
<LiveLogView
|
||||
logbookId={logbookId}
|
||||
onOpenEditor={(entryId) => {
|
||||
setReturnToLiveAfterEditor(true)
|
||||
setSelectedEntryId(entryId)
|
||||
}}
|
||||
onSwitchToList={() => setViewMode('list')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="tab-placeholder">
|
||||
@@ -365,6 +394,11 @@ export default function LogEntriesList({
|
||||
)
|
||||
}
|
||||
|
||||
const tourFirstEntryId =
|
||||
highlightEntryId && entries.some((e) => e.id === highlightEntryId)
|
||||
? highlightEntryId
|
||||
: entries[0]?.id ?? null
|
||||
|
||||
return (
|
||||
<div className="form-card">
|
||||
<div className="section-title-bar mb-6">
|
||||
@@ -373,6 +407,29 @@ export default function LogEntriesList({
|
||||
<h2>{t('logs.title')}</h2>
|
||||
</div>
|
||||
<div className="section-toolbar">
|
||||
{!readOnly && (
|
||||
<div className="logs-view-toggle" role="group" aria-label={t('logs.view_mode_label')}>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn secondary logs-view-toggle-btn ${viewMode === 'list' ? 'is-active' : ''}`}
|
||||
onClick={() => setViewMode('list')}
|
||||
title={t('logs.view_list')}
|
||||
>
|
||||
<List size={16} />
|
||||
<span className="hide-mobile">{t('logs.view_list')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn secondary logs-view-toggle-btn ${viewMode === 'live' ? 'is-active' : ''}`}
|
||||
onClick={() => setViewMode('live')}
|
||||
title={t('logs.live_mode')}
|
||||
>
|
||||
<Radio size={16} />
|
||||
<span className="hide-mobile">{t('logs.live_mode')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button className="btn secondary" onClick={handleDownloadCsv} disabled={loading || exporting || entries.length === 0} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.export_csv')}>
|
||||
<Download size={16} />
|
||||
<span className="hide-mobile">{exporting ? t('logs.exporting') : t('logs.export_csv')}</span>
|
||||
@@ -402,7 +459,7 @@ export default function LogEntriesList({
|
||||
<div
|
||||
key={item.id}
|
||||
className="logbook-card glass"
|
||||
data-tour={highlightEntryId === item.id ? 'entry-first' : undefined}
|
||||
data-tour={tourFirstEntryId === item.id ? 'entry-first' : undefined}
|
||||
onClick={() => setSelectedEntryId(item.id)}
|
||||
>
|
||||
<div className="card-icon">
|
||||
|
||||
@@ -6,7 +6,7 @@ import { getLogbookKey } from '../services/logbookKeys.js'
|
||||
import { encryptJson, decryptJson } from '../services/crypto.js'
|
||||
import { syncLogbook } from '../services/sync.js'
|
||||
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
||||
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X } from 'lucide-react'
|
||||
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import PhotoCapture from './PhotoCapture.tsx'
|
||||
import SignatureSection from './SignatureSection.tsx'
|
||||
import TrackMap from './TrackMap.tsx'
|
||||
@@ -22,7 +22,10 @@ import {
|
||||
hasAnySignature
|
||||
} from '../utils/signatures.js'
|
||||
import type { SignatureValue } from '../types/signatures.js'
|
||||
import { buildLogEntryPayload, sortLogEventsByTime, normalizeLogEvent, logEventsEqual, type LogEventPayload } from '../utils/logEntryPayload.js'
|
||||
import { buildLogEntryPayload, sortLogEventsByTime, normalizeLogEvent, hasUnsavedEventDraft, currentLocalTimeHHMM, isValidTimeHHMM, type LogEventPayload } from '../utils/logEntryPayload.js'
|
||||
import EventTimeInput24h from './EventTimeInput24h.tsx'
|
||||
import CourseDialInput from './CourseDialInput.tsx'
|
||||
import { parseOwmCurrentWeather } from '../utils/openWeatherMap.js'
|
||||
import { hashEntryForSigning } from '../utils/entryCanonicalHash.js'
|
||||
import { signLogEntry } from '../services/entrySigning.js'
|
||||
import { getLogbookAccess } from '../services/logbookAccess.js'
|
||||
@@ -34,11 +37,27 @@ import {
|
||||
deleteTrack,
|
||||
downloadTrackFile,
|
||||
parseTrackFile,
|
||||
type SavedTrack
|
||||
type SavedTrack,
|
||||
type TrackWaypoint
|
||||
} from '../services/trackUpload.js'
|
||||
import NmeaImportWizard from './NmeaImportWizard.tsx'
|
||||
import {
|
||||
deleteNmeaArchive,
|
||||
downloadNmeaArchive,
|
||||
getNmeaArchive,
|
||||
type NmeaArchiveRecord
|
||||
} from '../services/nmeaArchive.js'
|
||||
import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
|
||||
import { computeFuelPerMotorHour, formatFuelPerMotorHour } from '../utils/fuelStats.js'
|
||||
import { useRegisterUnsavedChanges } from '../context/UnsavedChangesContext.tsx'
|
||||
import TankLiterInput from './TankLiterInput.tsx'
|
||||
import {
|
||||
computeEveningTankMaxLiters,
|
||||
computeRefilledTankMaxLiters,
|
||||
extractTankCapacitiesFromYacht,
|
||||
formatTankLitersForInput,
|
||||
type VesselTankCapacities
|
||||
} from '../utils/tankCapacity.js'
|
||||
|
||||
function emptyTankLevels() {
|
||||
return { morning: 0, refilled: 0, evening: 0, consumption: 0 }
|
||||
@@ -47,6 +66,7 @@ function emptyTankLevels() {
|
||||
function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string {
|
||||
const fw = (decrypted.freshwater as Record<string, number> | undefined) ?? emptyTankLevels()
|
||||
const fuel = (decrypted.fuel as Record<string, number> | undefined) ?? emptyTankLevels()
|
||||
const gw = decrypted.greywater as { level?: number } | undefined
|
||||
const trackDistance = decrypted.trackDistanceNm
|
||||
const trackSpeedMax = decrypted.trackSpeedMaxKn
|
||||
const trackSpeedAvg = decrypted.trackSpeedAvgKn
|
||||
@@ -69,6 +89,7 @@ function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string
|
||||
evening: fuel.evening || 0,
|
||||
consumption: fuel.consumption ?? 0
|
||||
},
|
||||
greywater: gw ? { level: gw.level || 0 } : undefined,
|
||||
trackDistanceNm:
|
||||
trackDistance != null && trackDistance !== ''
|
||||
? parseFloat(String(trackDistance))
|
||||
@@ -142,6 +163,9 @@ export default function LogEntryEditor({
|
||||
const [fuelEvening, setFuelEvening] = useState('0')
|
||||
const [fuelConsumption, setFuelConsumption] = useState('0')
|
||||
|
||||
const [greywaterLevel, setGreywaterLevel] = useState('0')
|
||||
const [tankCapacities, setTankCapacities] = useState<VesselTankCapacities>({})
|
||||
|
||||
// Signatures
|
||||
const [signSkipper, setSignSkipper] = useState<SignatureValue | ''>('')
|
||||
const [signCrew, setSignCrew] = useState<SignatureValue | ''>('')
|
||||
@@ -162,7 +186,7 @@ export default function LogEntryEditor({
|
||||
const [events, setEvents] = useState<LogEvent[]>([])
|
||||
|
||||
// Add Event Form State
|
||||
const [evTime, setEvTime] = useState('')
|
||||
const [evTime, setEvTime] = useState(() => currentLocalTimeHHMM())
|
||||
const [evMgk, setEvMgk] = useState('')
|
||||
const [evRwk, setEvRwk] = useState('')
|
||||
const [evWindPressure, setEvWindPressure] = useState('')
|
||||
@@ -173,12 +197,14 @@ export default function LogEntryEditor({
|
||||
const [evCurrent, setEvCurrent] = useState('')
|
||||
const [evHeel, setEvHeel] = useState('')
|
||||
const [evSailsOrMotor, setEvSailsOrMotor] = useState('')
|
||||
const [sailsPickerExpanded, setSailsPickerExpanded] = useState(false)
|
||||
const [evLogReading, setEvLogReading] = useState('')
|
||||
const [evDistance, setEvDistance] = useState('')
|
||||
const [evGpsLat, setEvGpsLat] = useState('')
|
||||
const [evGpsLng, setEvGpsLng] = useState('')
|
||||
const [evRemarks, setEvRemarks] = useState('')
|
||||
const [evLocationName, setEvLocationName] = useState('')
|
||||
const [activeCourseTab, setActiveCourseTab] = useState<'mgk' | 'rwk'>('mgk')
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
@@ -192,11 +218,14 @@ export default function LogEntryEditor({
|
||||
const [savedTrack, setSavedTrack] = useState<SavedTrack | null>(null)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const [uploadError, setUploadError] = useState<string | null>(null)
|
||||
const [nmeaWizardOpen, setNmeaWizardOpen] = useState(false)
|
||||
const [nmeaArchive, setNmeaArchive] = useState<NmeaArchiveRecord | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const lockedContentHashRef = useRef<string | null>(null)
|
||||
const contentReadyRef = useRef(false)
|
||||
const lastSignatureAlertHashRef = useRef<string | null>(null)
|
||||
const skipCrewSignClearRef = useRef(false)
|
||||
const entryHashSeqRef = useRef(0)
|
||||
const [editingEventIndex, setEditingEventIndex] = useState<number | null>(null)
|
||||
|
||||
const applyTrackStats = (waypoints: SavedTrack['waypoints']) => {
|
||||
@@ -243,6 +272,7 @@ export default function LogEntryEditor({
|
||||
evening: parseFloat(fuelEvening) || 0,
|
||||
consumption: parseFloat(fuelConsumption) || 0
|
||||
},
|
||||
greywater: { level: parseFloat(greywaterLevel) || 0 },
|
||||
trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined,
|
||||
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
|
||||
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
|
||||
@@ -253,6 +283,7 @@ export default function LogEntryEditor({
|
||||
date, dayOfTravel, departure, destination,
|
||||
fwMorning, fwRefilled, fwEvening, fwConsumption,
|
||||
fuelMorning, fuelRefilled, fuelEvening, fuelConsumption,
|
||||
greywaterLevel,
|
||||
trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn, motorHours,
|
||||
events
|
||||
])
|
||||
@@ -262,6 +293,38 @@ export default function LogEntryEditor({
|
||||
[fuelConsumption, motorHours]
|
||||
)
|
||||
|
||||
const tankCapacityTooltip = t('logs.tank_capacity_tooltip')
|
||||
|
||||
const fwRefilledMax = useMemo(
|
||||
() => computeRefilledTankMaxLiters(fwMorning, tankCapacities.freshwaterCapacityL),
|
||||
[fwMorning, tankCapacities.freshwaterCapacityL]
|
||||
)
|
||||
|
||||
const fwEveningMax = useMemo(
|
||||
() =>
|
||||
computeEveningTankMaxLiters(
|
||||
fwMorning,
|
||||
fwRefilled,
|
||||
tankCapacities.freshwaterCapacityL
|
||||
),
|
||||
[fwMorning, fwRefilled, tankCapacities.freshwaterCapacityL]
|
||||
)
|
||||
|
||||
const fuelRefilledMax = useMemo(
|
||||
() => computeRefilledTankMaxLiters(fuelMorning, tankCapacities.fuelCapacityL),
|
||||
[fuelMorning, tankCapacities.fuelCapacityL]
|
||||
)
|
||||
|
||||
const fuelEveningMax = useMemo(
|
||||
() =>
|
||||
computeEveningTankMaxLiters(
|
||||
fuelMorning,
|
||||
fuelRefilled,
|
||||
tankCapacities.fuelCapacityL
|
||||
),
|
||||
[fuelMorning, fuelRefilled, tankCapacities.fuelCapacityL]
|
||||
)
|
||||
|
||||
const currentFingerprint = useMemo(() => {
|
||||
const payload = buildPayloadForSigning()
|
||||
return JSON.stringify({
|
||||
@@ -299,13 +362,7 @@ export default function LogEntryEditor({
|
||||
}
|
||||
|
||||
const hasPendingEventForm = useMemo(() => {
|
||||
if (!evTime.trim()) return false
|
||||
const draft = buildEventFromForm()
|
||||
if (editingEventIndex !== null) {
|
||||
const original = events[editingEventIndex]
|
||||
return original ? !logEventsEqual(draft, original) : false
|
||||
}
|
||||
return true
|
||||
return hasUnsavedEventDraft(buildEventFromForm(), editingEventIndex, events)
|
||||
}, [
|
||||
evTime, evMgk, evRwk, evWindPressure, evWindDirection, evWindStrength, evSeaState,
|
||||
evWeatherIcon, evCurrent, evHeel, evSailsOrMotor, evLogReading, evDistance,
|
||||
@@ -326,16 +383,27 @@ export default function LogEntryEditor({
|
||||
onBack()
|
||||
}
|
||||
|
||||
const persistEntryToDb = useCallback(async (eventsOverride?: LogEvent[]) => {
|
||||
const persistEntryToDb = useCallback(async (
|
||||
options?: LogEvent[] | {
|
||||
eventsOverride?: LogEvent[]
|
||||
signSkipper?: SignatureValue | ''
|
||||
signCrew?: SignatureValue | ''
|
||||
}
|
||||
) => {
|
||||
if (readOnly) return
|
||||
|
||||
const normalized = Array.isArray(options) ? { eventsOverride: options } : (options ?? {})
|
||||
const eventsOverride = normalized.eventsOverride
|
||||
const skipperToSave = normalized.signSkipper !== undefined ? normalized.signSkipper : signSkipper
|
||||
const crewToSave = normalized.signCrew !== undefined ? normalized.signCrew : signCrew
|
||||
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
|
||||
const entryData = {
|
||||
...buildPayloadForSigning(eventsOverride),
|
||||
signSkipper: normalizedSerializedSignature(signSkipper),
|
||||
signCrew: normalizedSerializedSignature(signCrew)
|
||||
signSkipper: normalizedSerializedSignature(skipperToSave),
|
||||
signCrew: normalizedSerializedSignature(crewToSave)
|
||||
}
|
||||
|
||||
const encrypted = await encryptJson(entryData, masterKey)
|
||||
@@ -363,9 +431,14 @@ export default function LogEntryEditor({
|
||||
|
||||
setSavedFingerprint(JSON.stringify({
|
||||
...buildPayloadForSigning(eventsOverride),
|
||||
signSkipper: fingerprintSignature(signSkipper),
|
||||
signCrew: fingerprintSignature(signCrew)
|
||||
signSkipper: fingerprintSignature(skipperToSave),
|
||||
signCrew: fingerprintSignature(crewToSave)
|
||||
}))
|
||||
|
||||
const hash = await hashEntryForSigning(buildPayloadForSigning(eventsOverride))
|
||||
entryHashSeqRef.current += 1
|
||||
setEntryHash(hash)
|
||||
lockedContentHashRef.current = hasAnySignature(skipperToSave, crewToSave) ? hash : null
|
||||
}, [
|
||||
readOnly, logbookId, entryId, events, buildPayloadForSigning, signSkipper, signCrew
|
||||
])
|
||||
@@ -393,9 +466,11 @@ export default function LogEntryEditor({
|
||||
}, [logbookId])
|
||||
|
||||
useEffect(() => {
|
||||
const seq = ++entryHashSeqRef.current
|
||||
let cancelled = false
|
||||
hashEntryForSigning(buildPayloadForSigning()).then((hash) => {
|
||||
if (!cancelled) setEntryHash(hash)
|
||||
if (cancelled || seq !== entryHashSeqRef.current) return
|
||||
setEntryHash(hash)
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [buildPayloadForSigning])
|
||||
@@ -466,6 +541,7 @@ export default function LogEntryEditor({
|
||||
role: 'skipper'
|
||||
})
|
||||
setSignSkipper(signature)
|
||||
entryHashSeqRef.current += 1
|
||||
setEntryHash(hash)
|
||||
lockedContentHashRef.current = hash
|
||||
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'skipper' })
|
||||
@@ -484,6 +560,7 @@ export default function LogEntryEditor({
|
||||
role: 'crew'
|
||||
})
|
||||
setSignCrew(signature)
|
||||
entryHashSeqRef.current += 1
|
||||
setEntryHash(hash)
|
||||
lockedContentHashRef.current = hash
|
||||
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'crew' })
|
||||
@@ -507,11 +584,59 @@ export default function LogEntryEditor({
|
||||
setFuelConsumption(cons >= 0 ? String(cons) : '0')
|
||||
}, [fuelMorning, fuelRefilled, fuelEvening])
|
||||
|
||||
// Load Yacht Sails
|
||||
const fwRefilledNoCapacity =
|
||||
(tankCapacities.freshwaterCapacityL ?? 0) > 0 && fwRefilledMax == null
|
||||
const fuelRefilledNoCapacity =
|
||||
(tankCapacities.fuelCapacityL ?? 0) > 0 && fuelRefilledMax == null
|
||||
|
||||
useEffect(() => {
|
||||
async function loadYachtSails() {
|
||||
if (readOnly && preloadedYacht?.sails) {
|
||||
setYachtSails(preloadedYacht.sails)
|
||||
const refilled = parseFloat(fwRefilled) || 0
|
||||
if (fwRefilledMax == null) {
|
||||
if (fwRefilledNoCapacity && refilled > 0) {
|
||||
setFwRefilled(formatTankLitersForInput(0))
|
||||
}
|
||||
return
|
||||
}
|
||||
if (refilled > fwRefilledMax) {
|
||||
setFwRefilled(formatTankLitersForInput(fwRefilledMax))
|
||||
}
|
||||
}, [fwRefilledMax, fwRefilled, fwRefilledNoCapacity])
|
||||
|
||||
useEffect(() => {
|
||||
if (fwEveningMax == null) return
|
||||
const evening = parseFloat(fwEvening) || 0
|
||||
if (evening > fwEveningMax) {
|
||||
setFwEvening(formatTankLitersForInput(fwEveningMax))
|
||||
}
|
||||
}, [fwEveningMax, fwEvening])
|
||||
|
||||
useEffect(() => {
|
||||
const refilled = parseFloat(fuelRefilled) || 0
|
||||
if (fuelRefilledMax == null) {
|
||||
if (fuelRefilledNoCapacity && refilled > 0) {
|
||||
setFuelRefilled(formatTankLitersForInput(0))
|
||||
}
|
||||
return
|
||||
}
|
||||
if (refilled > fuelRefilledMax) {
|
||||
setFuelRefilled(formatTankLitersForInput(fuelRefilledMax))
|
||||
}
|
||||
}, [fuelRefilledMax, fuelRefilled, fuelRefilledNoCapacity])
|
||||
|
||||
useEffect(() => {
|
||||
if (fuelEveningMax == null) return
|
||||
const evening = parseFloat(fuelEvening) || 0
|
||||
if (evening > fuelEveningMax) {
|
||||
setFuelEvening(formatTankLitersForInput(fuelEveningMax))
|
||||
}
|
||||
}, [fuelEveningMax, fuelEvening])
|
||||
|
||||
// Load yacht sails and tank capacities
|
||||
useEffect(() => {
|
||||
async function loadYachtMeta() {
|
||||
if (readOnly && preloadedYacht) {
|
||||
if (preloadedYacht.sails) setYachtSails(preloadedYacht.sails)
|
||||
setTankCapacities(extractTankCapacitiesFromYacht(preloadedYacht))
|
||||
return
|
||||
}
|
||||
try {
|
||||
@@ -521,16 +646,19 @@ export default function LogEntryEditor({
|
||||
const yacht = await db.yachts.get(logbookId)
|
||||
if (yacht) {
|
||||
const decrypted = await decryptJson(yacht.encryptedData, yacht.iv, yacht.tag, masterKey)
|
||||
if (decrypted && decrypted.sails && Array.isArray(decrypted.sails)) {
|
||||
setYachtSails(decrypted.sails)
|
||||
if (decrypted) {
|
||||
if (decrypted.sails && Array.isArray(decrypted.sails)) {
|
||||
setYachtSails(decrypted.sails)
|
||||
}
|
||||
setTankCapacities(extractTankCapacitiesFromYacht(decrypted))
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load yacht sails in editor:', err)
|
||||
console.error('Failed to load yacht meta in editor:', err)
|
||||
}
|
||||
}
|
||||
loadYachtSails()
|
||||
}, [logbookId, preloadedYacht])
|
||||
loadYachtMeta()
|
||||
}, [logbookId, preloadedYacht, readOnly])
|
||||
|
||||
// Load entry details
|
||||
useEffect(() => {
|
||||
@@ -560,6 +688,11 @@ export default function LogEntryEditor({
|
||||
setFuelEvening(String(preloadedEntry.fuel.evening || 0))
|
||||
setFuelConsumption(String(preloadedEntry.fuel.consumption ?? 0))
|
||||
}
|
||||
if (preloadedEntry.greywater) {
|
||||
setGreywaterLevel(String(preloadedEntry.greywater.level || 0))
|
||||
} else {
|
||||
setGreywaterLevel('0')
|
||||
}
|
||||
|
||||
setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '')
|
||||
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
|
||||
@@ -593,6 +726,11 @@ export default function LogEntryEditor({
|
||||
setFuelEvening(String(decrypted.fuel.evening || 0))
|
||||
setFuelConsumption(String(decrypted.fuel.consumption ?? 0))
|
||||
}
|
||||
if (decrypted.greywater) {
|
||||
setGreywaterLevel(String(decrypted.greywater.level || 0))
|
||||
} else {
|
||||
setGreywaterLevel('0')
|
||||
}
|
||||
|
||||
setSignSkipper(normalizeSignature(decrypted.signSkipper) || '')
|
||||
setSignCrew(normalizeSignature(decrypted.signCrew) || '')
|
||||
@@ -634,6 +772,45 @@ export default function LogEntryEditor({
|
||||
loadTrack()
|
||||
}, [entryId, preloadedTrack])
|
||||
|
||||
const loadNmeaArchive = async () => {
|
||||
if (readOnly) return
|
||||
try {
|
||||
const archive = await getNmeaArchive(entryId)
|
||||
setNmeaArchive(archive)
|
||||
} catch {
|
||||
setNmeaArchive(null)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadNmeaArchive()
|
||||
}, [entryId, readOnly])
|
||||
|
||||
const handleNmeaImport = async (importedEvents: LogEventPayload[], waypoints?: TrackWaypoint[]) => {
|
||||
setEvents((prev) => sortLogEventsByTime([...prev, ...importedEvents]))
|
||||
if (waypoints && waypoints.length > 0) {
|
||||
try {
|
||||
const gpxLike = waypoints
|
||||
.map((wp) => ` <trkpt lat="${wp.lat}" lon="${wp.lng}"><time>${new Date(wp.timestamp).toISOString()}</time></trkpt>`)
|
||||
.join('\n')
|
||||
const content = `<?xml version="1.0"?><gpx><trk><trkseg>\n${gpxLike}\n</trkseg></trk></gpx>`
|
||||
await saveUploadedTrack(logbookId, entryId, content, waypoints, 'imported-from-nmea.nmea', 'nmea')
|
||||
applyTrackStats(waypoints)
|
||||
await loadTrack()
|
||||
trackPlausibleEvent(PlausibleEvents.GPS_TRACK_UPLOADED)
|
||||
} catch (err: unknown) {
|
||||
console.warn('Failed to save NMEA track:', err)
|
||||
}
|
||||
}
|
||||
await loadNmeaArchive()
|
||||
}
|
||||
|
||||
const handleDeleteNmeaArchive = async () => {
|
||||
if (!window.confirm(t('logs.nmea_archive_delete_confirm'))) return
|
||||
await deleteNmeaArchive(entryId)
|
||||
setNmeaArchive(null)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!savedTrack || savedTrack.waypoints.length < 2) return
|
||||
if (trackDistanceNm || trackSpeedMaxKn || trackSpeedAvgKn) return
|
||||
@@ -723,7 +900,10 @@ export default function LogEntryEditor({
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fetchOpenWeatherCurrent({ q: locationQuery })
|
||||
const data = await fetchOpenWeatherCurrent(
|
||||
{ q: locationQuery },
|
||||
{ analyticsSource: 'entry_editor_gps_lookup' }
|
||||
)
|
||||
const coord = data.coord as { lat?: number; lon?: number } | undefined
|
||||
if (coord?.lat !== undefined && coord?.lon !== undefined) {
|
||||
setEvGpsLat(Number(coord.lat).toFixed(6))
|
||||
@@ -778,7 +958,8 @@ export default function LogEntryEditor({
|
||||
const data = await fetchOpenWeatherCurrent(
|
||||
hasGps
|
||||
? { lat: evGpsLat, lon: evGpsLng }
|
||||
: { q: fallbackLocation }
|
||||
: { q: fallbackLocation },
|
||||
{ analyticsSource: 'entry_editor' }
|
||||
)
|
||||
|
||||
const coord = data.coord as { lat?: number; lon?: number } | undefined
|
||||
@@ -788,41 +969,11 @@ export default function LogEntryEditor({
|
||||
setEvGpsLng(Number(coord.lon).toFixed(6))
|
||||
}
|
||||
|
||||
const wind = data.wind as { speed?: number; deg?: number } | undefined
|
||||
const main = data.main as { pressure?: number } | undefined
|
||||
|
||||
// Convert wind speed m/s to Beaufort scale
|
||||
const mps = wind?.speed || 0
|
||||
let bft = 0
|
||||
if (mps < 0.3) bft = 0
|
||||
else if (mps < 1.6) bft = 1
|
||||
else if (mps < 3.4) bft = 2
|
||||
else if (mps < 5.5) bft = 3
|
||||
else if (mps < 8.0) bft = 4
|
||||
else if (mps < 10.8) bft = 5
|
||||
else if (mps < 13.9) bft = 6
|
||||
else if (mps < 17.2) bft = 7
|
||||
else if (mps < 20.8) bft = 8
|
||||
else if (mps < 24.5) bft = 9
|
||||
else if (mps < 28.5) bft = 10
|
||||
else if (mps < 32.7) bft = 11
|
||||
else bft = 12
|
||||
|
||||
setEvWindStrength(`${bft} Bft (${mps.toFixed(1)} m/s)`)
|
||||
setEvWindPressure(String(main?.pressure || ''))
|
||||
|
||||
// Calculate wind compass direction sector
|
||||
if (wind?.deg !== undefined) {
|
||||
const deg = wind.deg
|
||||
const sectors = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW']
|
||||
const index = Math.round(deg / 22.5) % 16
|
||||
setEvWindDirection(sectors[index])
|
||||
}
|
||||
|
||||
if (data.weather && Array.isArray(data.weather) && data.weather[0]) {
|
||||
const first = data.weather[0] as { icon?: string }
|
||||
if (first.icon) setEvWeatherIcon(first.icon)
|
||||
}
|
||||
const parsed = parseOwmCurrentWeather(data)
|
||||
setEvWindStrength(parsed.windStrength)
|
||||
setEvWindPressure(parsed.windPressure)
|
||||
if (parsed.windDirection) setEvWindDirection(parsed.windDirection)
|
||||
if (parsed.weatherIcon) setEvWeatherIcon(parsed.weatherIcon)
|
||||
|
||||
showAlert(t('settings.weather_success'))
|
||||
} catch (err) {
|
||||
@@ -841,6 +992,9 @@ export default function LogEntryEditor({
|
||||
? ['Großsegel', 'Genua', 'Fock', 'Spinnaker', 'Gennaker']
|
||||
: ['Mainsail', 'Genoa', 'Jib', 'Spinnaker', 'Gennaker']
|
||||
|
||||
const eventSailOptions = yachtSails.length > 0 ? yachtSails : defaultSails
|
||||
const showSailsPickerToggle = eventSailOptions.length + 1 > 6
|
||||
|
||||
const toggleSailOrMotor = (item: string) => {
|
||||
let currentItems = evSailsOrMotor
|
||||
.split(/\s*(?:\+|\bplus\b|,)\s*/i)
|
||||
@@ -864,8 +1018,17 @@ export default function LogEntryEditor({
|
||||
return currentItems.includes(item.toLowerCase())
|
||||
}
|
||||
|
||||
const motorPropulsionLabel = t('logs.motor_propulsion')
|
||||
const sortedEventSailOptions = [...eventSailOptions].sort((a, b) => {
|
||||
const aActive = isItemActive(a)
|
||||
const bActive = isItemActive(b)
|
||||
if (aActive === bActive) return 0
|
||||
return aActive ? -1 : 1
|
||||
})
|
||||
const isMotorActive = isItemActive(motorPropulsionLabel)
|
||||
|
||||
const clearEventForm = () => {
|
||||
setEvTime('')
|
||||
setEvTime(currentLocalTimeHHMM())
|
||||
setEvMgk('')
|
||||
setEvRwk('')
|
||||
setEvWindPressure('')
|
||||
@@ -883,6 +1046,7 @@ export default function LogEntryEditor({
|
||||
setEvRemarks('')
|
||||
setEvLocationName('')
|
||||
setEditingEventIndex(null)
|
||||
setSailsPickerExpanded(false)
|
||||
}
|
||||
|
||||
const fillEventForm = (ev: LogEvent) => {
|
||||
@@ -906,10 +1070,23 @@ export default function LogEntryEditor({
|
||||
setEvLocationName('')
|
||||
}
|
||||
|
||||
const resolveSignaturesAfterContentChange = (skipperOnly = false) => {
|
||||
const hadSkipper = !!signSkipper
|
||||
const hadCrew = !!signCrew
|
||||
const cleared = hadSkipper || (hadCrew && !skipperOnly)
|
||||
skipCrewSignClearRef.current = skipperOnly
|
||||
const nextSkipper: SignatureValue | '' = hadSkipper ? '' : signSkipper
|
||||
const nextCrew: SignatureValue | '' = hadCrew && !skipperOnly ? '' : signCrew
|
||||
if (cleared) {
|
||||
if (hadSkipper) setSignSkipper('')
|
||||
if (hadCrew && !skipperOnly) setSignCrew('')
|
||||
lockedContentHashRef.current = null
|
||||
}
|
||||
return { signSkipper: nextSkipper, signCrew: nextCrew, cleared }
|
||||
}
|
||||
|
||||
const markSkipperSignatureClearedForEventChange = () => {
|
||||
if (!signSkipper) return
|
||||
skipCrewSignClearRef.current = true
|
||||
setSignSkipper('')
|
||||
resolveSignaturesAfterContentChange(true)
|
||||
}
|
||||
|
||||
const handleEditEvent = (index: number) => {
|
||||
@@ -926,7 +1103,7 @@ export default function LogEntryEditor({
|
||||
|
||||
const handleSaveEvent = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (readOnly || !evTime) return
|
||||
if (readOnly || !isValidTimeHHMM(evTime)) return
|
||||
|
||||
const eventData = buildEventFromForm()
|
||||
const isEdit = editingEventIndex !== null
|
||||
@@ -999,11 +1176,20 @@ export default function LogEntryEditor({
|
||||
if (readOnly) return
|
||||
|
||||
let eventsToSave = events
|
||||
let signaturesForSave: { signSkipper: SignatureValue | ''; signCrew: SignatureValue | '' } | undefined
|
||||
|
||||
if (hasPendingEventForm) {
|
||||
const isEdit = editingEventIndex !== null
|
||||
if (isEdit && signSkipper) {
|
||||
markSkipperSignatureClearedForEventChange()
|
||||
const resolved = resolveSignaturesAfterContentChange(isEdit)
|
||||
signaturesForSave = {
|
||||
signSkipper: resolved.signSkipper,
|
||||
signCrew: resolved.signCrew
|
||||
}
|
||||
if (resolved.cleared) {
|
||||
void showAlertRef.current(
|
||||
isEdit ? t('logs.sign_cleared_skipper_re_sign') : t('logs.sign_cleared_re_sign'),
|
||||
isEdit ? t('logs.sign_cleared_skipper_re_sign_title') : t('logs.sign_cleared_re_sign_title')
|
||||
)
|
||||
}
|
||||
eventsToSave = applyEventFormToEvents(buildEventFromForm())
|
||||
setEvents(eventsToSave)
|
||||
@@ -1017,7 +1203,10 @@ export default function LogEntryEditor({
|
||||
setSuccess(false)
|
||||
|
||||
try {
|
||||
await persistEntryToDb(eventsToSave)
|
||||
await persistEntryToDb({
|
||||
eventsOverride: eventsToSave,
|
||||
...signaturesForSave
|
||||
})
|
||||
|
||||
setSuccess(true)
|
||||
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
|
||||
@@ -1155,41 +1344,35 @@ export default function LogEntryEditor({
|
||||
<h3>{t('logs.freshwater')}</h3>
|
||||
</div>
|
||||
<div className="consumption-grid">
|
||||
<TankLiterInput
|
||||
id="fw-morning"
|
||||
label={t('logs.morning')}
|
||||
value={fwMorning}
|
||||
onChange={setFwMorning}
|
||||
maxLiters={tankCapacities.freshwaterCapacityL}
|
||||
disabled={saving || readOnly}
|
||||
titleTooltip={tankCapacityTooltip}
|
||||
/>
|
||||
<TankLiterInput
|
||||
id="fw-refilled"
|
||||
label={t('logs.refilled')}
|
||||
value={fwRefilled}
|
||||
onChange={setFwRefilled}
|
||||
maxLiters={fwRefilledMax}
|
||||
disabled={saving || readOnly || fwRefilledNoCapacity}
|
||||
titleTooltip={tankCapacityTooltip}
|
||||
/>
|
||||
<TankLiterInput
|
||||
id="fw-evening"
|
||||
label={t('logs.evening')}
|
||||
value={fwEvening}
|
||||
onChange={setFwEvening}
|
||||
maxLiters={fwEveningMax}
|
||||
disabled={saving || readOnly}
|
||||
titleTooltip={tankCapacityTooltip}
|
||||
/>
|
||||
<div className="input-group">
|
||||
<label>{t('logs.morning')}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-text"
|
||||
value={fwMorning}
|
||||
onChange={(e) => setFwMorning(e.target.value)}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.refilled')}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-text"
|
||||
value={fwRefilled}
|
||||
onChange={(e) => setFwRefilled(e.target.value)}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.evening')}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-text"
|
||||
value={fwEvening}
|
||||
onChange={(e) => setFwEvening(e.target.value)}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.consumption')} (L)</label>
|
||||
<label title={tankCapacityTooltip}>{t('logs.consumption')} (L)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-text consumption-value"
|
||||
@@ -1197,6 +1380,7 @@ export default function LogEntryEditor({
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
aria-readonly="true"
|
||||
title={tankCapacityTooltip}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1209,41 +1393,35 @@ export default function LogEntryEditor({
|
||||
<h3>{t('logs.fuel')}</h3>
|
||||
</div>
|
||||
<div className="consumption-grid">
|
||||
<TankLiterInput
|
||||
id="fuel-morning"
|
||||
label={t('logs.morning')}
|
||||
value={fuelMorning}
|
||||
onChange={setFuelMorning}
|
||||
maxLiters={tankCapacities.fuelCapacityL}
|
||||
disabled={saving || readOnly}
|
||||
titleTooltip={tankCapacityTooltip}
|
||||
/>
|
||||
<TankLiterInput
|
||||
id="fuel-refilled"
|
||||
label={t('logs.refilled')}
|
||||
value={fuelRefilled}
|
||||
onChange={setFuelRefilled}
|
||||
maxLiters={fuelRefilledMax}
|
||||
disabled={saving || readOnly || fuelRefilledNoCapacity}
|
||||
titleTooltip={tankCapacityTooltip}
|
||||
/>
|
||||
<TankLiterInput
|
||||
id="fuel-evening"
|
||||
label={t('logs.evening')}
|
||||
value={fuelEvening}
|
||||
onChange={setFuelEvening}
|
||||
maxLiters={fuelEveningMax}
|
||||
disabled={saving || readOnly}
|
||||
titleTooltip={tankCapacityTooltip}
|
||||
/>
|
||||
<div className="input-group">
|
||||
<label>{t('logs.morning')}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-text"
|
||||
value={fuelMorning}
|
||||
onChange={(e) => setFuelMorning(e.target.value)}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.refilled')}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-text"
|
||||
value={fuelRefilled}
|
||||
onChange={(e) => setFuelRefilled(e.target.value)}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.evening')}</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-text"
|
||||
value={fuelEvening}
|
||||
onChange={(e) => setFuelEvening(e.target.value)}
|
||||
disabled={saving || readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.consumption')} (L)</label>
|
||||
<label title={tankCapacityTooltip}>{t('logs.consumption')} (L)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-text consumption-value"
|
||||
@@ -1251,11 +1429,12 @@ export default function LogEntryEditor({
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
aria-readonly="true"
|
||||
title={tankCapacityTooltip}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.fuel_per_motor_hour')}</label>
|
||||
<label title={tankCapacityTooltip}>{t('logs.fuel_per_motor_hour')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-text consumption-value"
|
||||
@@ -1267,10 +1446,30 @@ export default function LogEntryEditor({
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
aria-readonly="true"
|
||||
title={tankCapacityTooltip}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Greywater card */}
|
||||
<div className="form-card">
|
||||
<div className="form-header">
|
||||
<Compass size={20} className="form-icon" />
|
||||
<h3>{t('logs.greywater')}</h3>
|
||||
</div>
|
||||
<div className="consumption-grid">
|
||||
<TankLiterInput
|
||||
id="greywater-level"
|
||||
label={t('logs.greywater_level')}
|
||||
value={greywaterLevel}
|
||||
onChange={setGreywaterLevel}
|
||||
maxLiters={tankCapacities.greywaterCapacityL}
|
||||
disabled={saving || readOnly}
|
||||
titleTooltip={tankCapacityTooltip}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 3: Event Journal Entries */}
|
||||
@@ -1368,36 +1567,46 @@ export default function LogEntryEditor({
|
||||
<Clock size={12} style={{ display: 'inline', marginRight: 4 }} />
|
||||
{t('logs.event_time')} *
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
className="input-text"
|
||||
<EventTimeInput24h
|
||||
value={evTime}
|
||||
onChange={(e) => setEvTime(e.target.value)}
|
||||
onChange={setEvTime}
|
||||
disabled={saving}
|
||||
aria-label={t('logs.event_time')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.event_mgk')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. 180"
|
||||
className="input-text"
|
||||
value={evMgk}
|
||||
onChange={(e) => setEvMgk(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('logs.event_rwk')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. 185"
|
||||
className="input-text"
|
||||
value={evRwk}
|
||||
onChange={(e) => setEvRwk(e.target.value)}
|
||||
<div className="input-group course-dial-section">
|
||||
<label>
|
||||
<Compass size={12} style={{ display: 'inline', marginRight: 4 }} />
|
||||
{t('logs.event_course_section')}
|
||||
</label>
|
||||
<div className="course-dial-tabs" role="tablist" aria-label={t('logs.event_course_section')}>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeCourseTab === 'mgk'}
|
||||
className={`course-dial-tab${activeCourseTab === 'mgk' ? ' is-active' : ''}`}
|
||||
onClick={() => setActiveCourseTab('mgk')}
|
||||
disabled={saving}
|
||||
>
|
||||
{t('logs.course_tab_mgk')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeCourseTab === 'rwk'}
|
||||
className={`course-dial-tab${activeCourseTab === 'rwk' ? ' is-active' : ''}`}
|
||||
onClick={() => setActiveCourseTab('rwk')}
|
||||
disabled={saving}
|
||||
>
|
||||
{t('logs.course_tab_rwk')}
|
||||
</button>
|
||||
</div>
|
||||
<CourseDialInput
|
||||
value={activeCourseTab === 'mgk' ? evMgk : evRwk}
|
||||
onChange={activeCourseTab === 'mgk' ? setEvMgk : setEvRwk}
|
||||
disabled={saving}
|
||||
aria-label={activeCourseTab === 'mgk' ? t('logs.event_mgk') : t('logs.event_rwk')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1476,15 +1685,15 @@ export default function LogEntryEditor({
|
||||
</div>
|
||||
|
||||
<div className="form-grid mb-4">
|
||||
<div className="input-group">
|
||||
<div className="input-group course-dial-section">
|
||||
<label>{t('logs.event_wind_direction')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. NNE"
|
||||
className="input-text"
|
||||
<CourseDialInput
|
||||
value={evWindDirection}
|
||||
onChange={(e) => setEvWindDirection(e.target.value)}
|
||||
onChange={setEvWindDirection}
|
||||
disabled={saving || weatherLoading}
|
||||
allowCardinal
|
||||
displayMode="auto"
|
||||
aria-label={t('logs.event_wind_direction')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1548,25 +1757,6 @@ export default function LogEntryEditor({
|
||||
onChange={(e) => setEvSailsOrMotor(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<div className="sails-picker-container">
|
||||
<div className="sails-picker-pills">
|
||||
{(yachtSails.length > 0 ? yachtSails : defaultSails).map((sail) => (
|
||||
<span
|
||||
key={sail}
|
||||
className={`sail-pill ${isItemActive(sail) ? 'active' : ''}`}
|
||||
onClick={() => toggleSailOrMotor(sail)}
|
||||
>
|
||||
{sail}
|
||||
</span>
|
||||
))}
|
||||
<span
|
||||
className={`sail-pill motor-pill ${isItemActive(t('logs.motor_propulsion')) ? 'active' : ''}`}
|
||||
onClick={() => toggleSailOrMotor(t('logs.motor_propulsion'))}
|
||||
>
|
||||
{t('logs.motor_propulsion')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
@@ -1581,7 +1771,63 @@ export default function LogEntryEditor({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group" style={{ gridColumn: 'span 2' }}>
|
||||
<div
|
||||
className={[
|
||||
'sails-picker-container grid-span-2',
|
||||
showSailsPickerToggle ? 'is-collapsible' : '',
|
||||
showSailsPickerToggle && !sailsPickerExpanded ? 'is-collapsed' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
<div className="sails-picker-pills">
|
||||
{isMotorActive && (
|
||||
<span
|
||||
className={`sail-pill motor-pill active`}
|
||||
onClick={() => toggleSailOrMotor(motorPropulsionLabel)}
|
||||
>
|
||||
{motorPropulsionLabel}
|
||||
</span>
|
||||
)}
|
||||
{sortedEventSailOptions.map((sail) => (
|
||||
<span
|
||||
key={sail}
|
||||
className={`sail-pill ${isItemActive(sail) ? 'active' : ''}`}
|
||||
onClick={() => toggleSailOrMotor(sail)}
|
||||
>
|
||||
{sail}
|
||||
</span>
|
||||
))}
|
||||
{!isMotorActive && (
|
||||
<span
|
||||
className="sail-pill motor-pill"
|
||||
onClick={() => toggleSailOrMotor(motorPropulsionLabel)}
|
||||
>
|
||||
{motorPropulsionLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{showSailsPickerToggle && (
|
||||
<button
|
||||
type="button"
|
||||
className="sails-picker-toggle"
|
||||
onClick={() => setSailsPickerExpanded((prev) => !prev)}
|
||||
aria-expanded={sailsPickerExpanded}
|
||||
>
|
||||
{sailsPickerExpanded ? (
|
||||
<>
|
||||
<ChevronUp size={14} aria-hidden="true" />
|
||||
{t('logs.sails_picker_show_less')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown size={14} aria-hidden="true" />
|
||||
{t('logs.sails_picker_show_more')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="input-group grid-span-2">
|
||||
<label>{t('logs.event_remarks')}</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -1611,7 +1857,7 @@ export default function LogEntryEditor({
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={handleSaveEvent}
|
||||
disabled={saving || !evTime}
|
||||
disabled={saving || !isValidTimeHHMM(evTime)}
|
||||
style={{ width: 'auto', padding: '10px 20px', display: 'flex' }}
|
||||
>
|
||||
{editingEventIndex !== null ? <Save size={16} /> : <Plus size={16} />}
|
||||
@@ -1705,6 +1951,31 @@ export default function LogEntryEditor({
|
||||
</>
|
||||
)}
|
||||
|
||||
{!readOnly && (
|
||||
<div className="nmea-import-section" style={{ marginTop: '12px' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => setNmeaWizardOpen(true)}
|
||||
style={{ width: 'auto', padding: '8px 14px', display: 'inline-flex', alignItems: 'center', gap: '6px' }}
|
||||
>
|
||||
<FileText size={16} />
|
||||
{t('logs.nmea_import_btn')}
|
||||
</button>
|
||||
{nmeaArchive && (
|
||||
<div className="nmea-archive-info" style={{ marginTop: '8px', display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<span>{t('logs.nmea_archive_stored', { name: nmeaArchive.filename })}</span>
|
||||
<button type="button" className="btn secondary" style={{ width: 'auto', padding: '4px 10px', fontSize: '13px' }} onClick={() => downloadNmeaArchive(nmeaArchive)}>
|
||||
<Download size={14} />
|
||||
</button>
|
||||
<button type="button" className="btn secondary" style={{ width: 'auto', padding: '4px 10px', fontSize: '13px' }} onClick={handleDeleteNmeaArchive}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(savedTrack || trackDistanceNm || trackSpeedMaxKn || trackSpeedAvgKn) && (
|
||||
<div className="form-grid track-stats-grid">
|
||||
<div className="input-group">
|
||||
@@ -1810,6 +2081,19 @@ export default function LogEntryEditor({
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<NmeaImportWizard
|
||||
open={nmeaWizardOpen}
|
||||
onClose={() => {
|
||||
setNmeaWizardOpen(false)
|
||||
void loadNmeaArchive()
|
||||
}}
|
||||
logbookId={logbookId}
|
||||
entryId={entryId}
|
||||
entryDate={date}
|
||||
nmeaArchive={nmeaArchive}
|
||||
onImport={handleNmeaImport}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type LogbookBackupPreview
|
||||
} from '../services/logbookBackup.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { formatAppDateTime } from '../utils/dateTimeFormat.js'
|
||||
|
||||
interface LogbookBackupPanelProps {
|
||||
logbookId: string
|
||||
@@ -41,7 +42,7 @@ function mapBackupError(code: string, t: (key: string) => string): string {
|
||||
}
|
||||
|
||||
export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBackupPanelProps) {
|
||||
const { t } = useTranslation()
|
||||
const { t, i18n } = useTranslation()
|
||||
const { showConfirm } = useDialog()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
@@ -334,7 +335,7 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
||||
</ul>
|
||||
<p className="text-muted backup-preview-date">
|
||||
{t('settings.backup_exported_at', {
|
||||
date: new Date(importPreview.exportedAt).toLocaleString()
|
||||
date: formatAppDateTime(importPreview.exportedAt, i18n.language)
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import { db } from '../services/db.js'
|
||||
import { cycleAppLanguage } from '../utils/i18nLanguages.js'
|
||||
import { useSyncIndicator } from '../hooks/useSyncIndicator.js'
|
||||
import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type DecryptedLogbook } from '../services/logbook.js'
|
||||
import LogbookRoleBadge from './LogbookRoleBadge.tsx'
|
||||
import BetaBadge from './BetaBadge.tsx'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { logoutUser } from '../services/auth.js'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, User, Wifi, WifiOff } from 'lucide-react'
|
||||
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, Wifi, WifiOff, Search, X, CalendarDays, CaseSensitive, ArrowUp, ArrowDown } from 'lucide-react'
|
||||
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
|
||||
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
|
||||
import ProfileHeaderButton from './ProfileHeaderButton.tsx'
|
||||
|
||||
interface LogbookDashboardProps {
|
||||
onSelectLogbook: (id: string, title: string) => void
|
||||
@@ -18,6 +19,46 @@ interface LogbookDashboardProps {
|
||||
onOpenProfile: () => void
|
||||
}
|
||||
|
||||
function logbookMatchesFilter(lb: DecryptedLogbook, query: string, locale: string): boolean {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return true
|
||||
|
||||
if (lb.title.toLowerCase().includes(q)) return true
|
||||
|
||||
const updated = new Date(lb.updatedAt)
|
||||
const year = updated.getFullYear().toString()
|
||||
if (year.includes(q)) return true
|
||||
|
||||
const dateLabel = updated.toLocaleDateString(locale, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
}).toLowerCase()
|
||||
if (dateLabel.includes(q)) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
type LogbookSortKey = 'name' | 'date'
|
||||
type LogbookSortDirection = 'asc' | 'desc'
|
||||
|
||||
function sortLogbooks(
|
||||
items: DecryptedLogbook[],
|
||||
sortBy: LogbookSortKey,
|
||||
direction: LogbookSortDirection,
|
||||
locale: string
|
||||
): DecryptedLogbook[] {
|
||||
const sorted = [...items]
|
||||
sorted.sort((a, b) => {
|
||||
const cmp =
|
||||
sortBy === 'name'
|
||||
? a.title.localeCompare(b.title, locale, { sensitivity: 'base' })
|
||||
: new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
|
||||
return direction === 'asc' ? cmp : -cmp
|
||||
})
|
||||
return sorted
|
||||
}
|
||||
|
||||
export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProfile }: LogbookDashboardProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { showConfirm } = useDialog()
|
||||
@@ -29,11 +70,13 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [filterQuery, setFilterQuery] = useState('')
|
||||
const [sortBy, setSortBy] = useState<LogbookSortKey>('date')
|
||||
const [sortDirection, setSortDirection] = useState<LogbookSortDirection>('desc')
|
||||
const filterInputRef = useRef<HTMLInputElement>(null)
|
||||
const [online, setOnline] = useState(navigator.onLine)
|
||||
const [username] = useState(localStorage.getItem('active_username') || 'Skipper')
|
||||
|
||||
// Reactive sync queue count
|
||||
const pendingCount = useLiveQuery(() => db.syncQueue.count()) || 0
|
||||
const { pendingCount, showSpinner, showPendingWarning, connStatusClassName } = useSyncIndicator()
|
||||
|
||||
// Listen to connectivity changes
|
||||
useEffect(() => {
|
||||
@@ -151,13 +194,31 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
||||
}
|
||||
|
||||
const toggleLanguage = () => {
|
||||
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
|
||||
i18n.changeLanguage(nextLang)
|
||||
cycleAppLanguage(i18n)
|
||||
}
|
||||
|
||||
const ownedLogbooks = logbooks.filter((lb) => !lb.isShared)
|
||||
const sharedLogbooks = logbooks.filter((lb) => lb.isShared)
|
||||
|
||||
const filterActive = filterQuery.trim().length > 0
|
||||
const filteredOwnedLogbooks = useMemo(
|
||||
() => ownedLogbooks.filter((lb) => logbookMatchesFilter(lb, filterQuery, i18n.language)),
|
||||
[ownedLogbooks, filterQuery, i18n.language]
|
||||
)
|
||||
const filteredSharedLogbooks = useMemo(
|
||||
() => sharedLogbooks.filter((lb) => logbookMatchesFilter(lb, filterQuery, i18n.language)),
|
||||
[sharedLogbooks, filterQuery, i18n.language]
|
||||
)
|
||||
const sortedOwnedLogbooks = useMemo(
|
||||
() => sortLogbooks(filteredOwnedLogbooks, sortBy, sortDirection, i18n.language),
|
||||
[filteredOwnedLogbooks, sortBy, sortDirection, i18n.language]
|
||||
)
|
||||
const sortedSharedLogbooks = useMemo(
|
||||
() => sortLogbooks(filteredSharedLogbooks, sortBy, sortDirection, i18n.language),
|
||||
[filteredSharedLogbooks, sortBy, sortDirection, i18n.language]
|
||||
)
|
||||
const filteredLogbookCount = sortedOwnedLogbooks.length + sortedSharedLogbooks.length
|
||||
|
||||
const renderLogbookCard = (lb: DecryptedLogbook) => {
|
||||
const isEditingTitle = editingLogbookId === lb.id
|
||||
|
||||
@@ -272,11 +333,27 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
||||
|
||||
<div className="header-actions">
|
||||
{/* Connection Indicator */}
|
||||
<div className={`conn-status ${online ? (pendingCount > 0 ? 'unsynced' : 'online') : 'offline'}`} title={online ? (pendingCount > 0 ? 'Pending Sync' : 'Synced') : 'Offline'}>
|
||||
<div
|
||||
className={connStatusClassName(online)}
|
||||
title={
|
||||
online
|
||||
? showSpinner
|
||||
? 'Syncing'
|
||||
: pendingCount > 0
|
||||
? 'Pending Sync'
|
||||
: 'Synced'
|
||||
: 'Offline'
|
||||
}
|
||||
>
|
||||
{online ? (
|
||||
pendingCount > 0 ? (
|
||||
showSpinner ? (
|
||||
<>
|
||||
<RefreshCw size={18} className="spin" />
|
||||
<span>{t('sync.status_syncing')}</span>
|
||||
</>
|
||||
) : showPendingWarning ? (
|
||||
<>
|
||||
<RefreshCw size={18} />
|
||||
<span>{t('sync.status_unsynced')} ({pendingCount})</span>
|
||||
</>
|
||||
) : (
|
||||
@@ -293,17 +370,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Skipper profile */}
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon skipper-badge"
|
||||
onClick={onOpenProfile}
|
||||
title={t('dashboard.open_profile', { name: username })}
|
||||
aria-label={t('dashboard.open_profile', { name: username })}
|
||||
>
|
||||
<User size={18} aria-hidden="true" />
|
||||
<span className="skipper-badge__name">{username}</span>
|
||||
</button>
|
||||
<ProfileHeaderButton onClick={onOpenProfile} />
|
||||
|
||||
{/* Lang toggle */}
|
||||
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
|
||||
@@ -361,17 +428,115 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
||||
) : logbooks.length === 0 ? (
|
||||
<div className="dashboard-status-msg glass">{t('dashboard.no_logbooks')}</div>
|
||||
) : (
|
||||
<div className="logbook-sections">
|
||||
{ownedLogbooks.length > 0 && renderLogbookSection(
|
||||
sharedLogbooks.length > 0 ? t('dashboard.section_owned') : t('dashboard.title'),
|
||||
ownedLogbooks
|
||||
<>
|
||||
<div className="dashboard-list-controls">
|
||||
<div className="dashboard-filter-bar">
|
||||
<label className="dashboard-filter-label" htmlFor="logbook-list-filter">
|
||||
{t('dashboard.filter_label')}
|
||||
</label>
|
||||
<div className="dashboard-filter-input-wrap">
|
||||
<Search size={18} className="dashboard-filter-icon" aria-hidden="true" />
|
||||
<input
|
||||
ref={filterInputRef}
|
||||
id="logbook-list-filter"
|
||||
type="search"
|
||||
className="input-text dashboard-filter-input"
|
||||
placeholder={t('dashboard.filter_placeholder')}
|
||||
value={filterQuery}
|
||||
onChange={(e) => setFilterQuery(e.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
aria-describedby={filterActive ? 'logbook-filter-status' : undefined}
|
||||
/>
|
||||
{filterActive && (
|
||||
<button
|
||||
type="button"
|
||||
className="dashboard-filter-clear"
|
||||
onClick={() => {
|
||||
setFilterQuery('')
|
||||
filterInputRef.current?.focus()
|
||||
}}
|
||||
title={t('dashboard.filter_clear')}
|
||||
aria-label={t('dashboard.filter_clear')}
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{filterActive && (
|
||||
<p id="logbook-filter-status" className="dashboard-filter-meta" role="status">
|
||||
{t('dashboard.filter_results', { count: filteredLogbookCount })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="dashboard-sort-bar">
|
||||
<span className="dashboard-sort-label">{t('dashboard.sort_label')}</span>
|
||||
<div className="dashboard-sort-row">
|
||||
<div className="dashboard-sort-group" role="group" aria-label={t('dashboard.sort_by_label')}>
|
||||
<button
|
||||
type="button"
|
||||
className={`dashboard-sort-btn${sortBy === 'name' ? ' is-active' : ''}`}
|
||||
onClick={() => setSortBy('name')}
|
||||
aria-pressed={sortBy === 'name'}
|
||||
aria-label={t('dashboard.sort_by_name')}
|
||||
title={t('dashboard.sort_by_name')}
|
||||
>
|
||||
<CaseSensitive size={16} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`dashboard-sort-btn${sortBy === 'date' ? ' is-active' : ''}`}
|
||||
onClick={() => setSortBy('date')}
|
||||
aria-pressed={sortBy === 'date'}
|
||||
aria-label={t('dashboard.sort_by_date')}
|
||||
title={t('dashboard.sort_by_date')}
|
||||
>
|
||||
<CalendarDays size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="dashboard-sort-group" role="group" aria-label={t('dashboard.sort_dir_label')}>
|
||||
<button
|
||||
type="button"
|
||||
className={`dashboard-sort-btn${sortDirection === 'asc' ? ' is-active' : ''}`}
|
||||
onClick={() => setSortDirection('asc')}
|
||||
aria-pressed={sortDirection === 'asc'}
|
||||
aria-label={sortBy === 'name' ? t('dashboard.sort_name_asc') : t('dashboard.sort_date_asc')}
|
||||
title={sortBy === 'name' ? t('dashboard.sort_name_asc') : t('dashboard.sort_date_asc')}
|
||||
>
|
||||
<ArrowUp size={16} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`dashboard-sort-btn${sortDirection === 'desc' ? ' is-active' : ''}`}
|
||||
onClick={() => setSortDirection('desc')}
|
||||
aria-pressed={sortDirection === 'desc'}
|
||||
aria-label={sortBy === 'name' ? t('dashboard.sort_name_desc') : t('dashboard.sort_date_desc')}
|
||||
title={sortBy === 'name' ? t('dashboard.sort_name_desc') : t('dashboard.sort_date_desc')}
|
||||
>
|
||||
<ArrowDown size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filterActive && filteredLogbookCount === 0 ? (
|
||||
<div className="dashboard-status-msg glass">{t('dashboard.filter_no_results')}</div>
|
||||
) : (
|
||||
<div className="logbook-sections">
|
||||
{sortedOwnedLogbooks.length > 0 && renderLogbookSection(
|
||||
sortedSharedLogbooks.length > 0 ? t('dashboard.section_owned') : t('dashboard.title'),
|
||||
sortedOwnedLogbooks
|
||||
)}
|
||||
{sortedSharedLogbooks.length > 0 && renderLogbookSection(
|
||||
t('dashboard.section_shared'),
|
||||
sortedSharedLogbooks,
|
||||
t('dashboard.section_shared_hint')
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{sharedLogbooks.length > 0 && renderLogbookSection(
|
||||
t('dashboard.section_shared'),
|
||||
sharedLogbooks,
|
||||
t('dashboard.section_shared_hint')
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FileText, X } from 'lucide-react'
|
||||
import type { LogEventPayload } from '../utils/logEntryPayload.js'
|
||||
import { sortLogEventsByTime } from '../utils/logEntryPayload.js'
|
||||
import { parseNmeaFile, nmeaPointsToWaypoints } from '../services/nmea/nmeaParse.js'
|
||||
import { filterPointsForDate } from '../services/nmea/nmeaTimeSeries.js'
|
||||
import { generateNmeaJournalCandidates } from '../services/nmea/nmeaJournalGenerator.js'
|
||||
import type { NmeaImportMode, NmeaParseResult } from '../services/nmea/nmeaTypes.js'
|
||||
import { saveNmeaArchive, recordNmeaFileImport, type NmeaArchiveRecord } from '../services/nmeaArchive.js'
|
||||
import { nmeaFileCrc32 } from '../utils/crc32.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import type { TrackWaypoint } from '../services/trackUpload.js'
|
||||
|
||||
interface NmeaImportWizardProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
logbookId: string
|
||||
entryId: string
|
||||
entryDate: string
|
||||
nmeaArchive: NmeaArchiveRecord | null
|
||||
onImport: (events: LogEventPayload[], waypoints?: TrackWaypoint[]) => void
|
||||
}
|
||||
|
||||
type WizardStep = 'config' | 'preview' | 'archive'
|
||||
|
||||
export default function NmeaImportWizard({
|
||||
open,
|
||||
onClose,
|
||||
logbookId,
|
||||
entryId,
|
||||
entryDate,
|
||||
nmeaArchive,
|
||||
onImport
|
||||
}: NmeaImportWizardProps) {
|
||||
const { t } = useTranslation()
|
||||
const [step, setStep] = useState<WizardStep>('config')
|
||||
const [parseResult, setParseResult] = useState<NmeaParseResult | null>(null)
|
||||
const [mode, setMode] = useState<NmeaImportMode>('both')
|
||||
const [intervalMinutes, setIntervalMinutes] = useState(60)
|
||||
const [importTrack, setImportTrack] = useState(true)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [pendingRaw, setPendingRaw] = useState<{ filename: string; text: string } | null>(null)
|
||||
const [duplicateFile, setDuplicateFile] = useState(false)
|
||||
|
||||
const filteredPoints = useMemo(() => {
|
||||
if (!parseResult) return []
|
||||
return filterPointsForDate(parseResult.points, entryDate)
|
||||
}, [parseResult, entryDate])
|
||||
|
||||
const candidates = useMemo(() => {
|
||||
if (!parseResult || filteredPoints.length === 0) return []
|
||||
return generateNmeaJournalCandidates({
|
||||
points: filteredPoints,
|
||||
mode,
|
||||
intervalMinutes,
|
||||
t
|
||||
}).candidates
|
||||
}, [parseResult, filteredPoints, mode, intervalMinutes, t])
|
||||
|
||||
const reset = () => {
|
||||
setStep('config')
|
||||
setParseResult(null)
|
||||
setMode('both')
|
||||
setIntervalMinutes(60)
|
||||
setImportTrack(true)
|
||||
setSelectedIds(new Set())
|
||||
setError(null)
|
||||
setDuplicateFile(false)
|
||||
setPendingRaw(null)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
reset()
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleFile = (file: File) => {
|
||||
setError(null)
|
||||
setDuplicateFile(false)
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
try {
|
||||
const text = String(reader.result ?? '')
|
||||
const crc32 = nmeaFileCrc32(text)
|
||||
const alreadyImported = nmeaArchive?.importedFiles.some((item) => item.crc32 === crc32) ?? false
|
||||
setDuplicateFile(alreadyImported)
|
||||
const result = parseNmeaFile(text, file.name)
|
||||
if (result.points.length === 0) {
|
||||
setError(t('logs.nmea_error_no_samples'))
|
||||
return
|
||||
}
|
||||
setParseResult(result)
|
||||
setPendingRaw({ filename: file.name, text })
|
||||
const generated = generateNmeaJournalCandidates({
|
||||
points: filterPointsForDate(result.points, entryDate),
|
||||
mode,
|
||||
intervalMinutes,
|
||||
t
|
||||
}).candidates
|
||||
setSelectedIds(new Set(generated.map((c) => c.id)))
|
||||
trackPlausibleEvent(PlausibleEvents.NMEA_UPLOADED, {
|
||||
duplicate: alreadyImported,
|
||||
lines: result.stats.parsedLines,
|
||||
candidates: generated.length,
|
||||
has_position: !result.warnings.includes('no_position')
|
||||
})
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('logs.nmea_error_parse'))
|
||||
}
|
||||
}
|
||||
reader.onerror = () => setError(t('logs.nmea_error_read'))
|
||||
reader.readAsText(file)
|
||||
}
|
||||
|
||||
const toggleAll = (checked: boolean) => {
|
||||
setSelectedIds(checked ? new Set(candidates.map((c) => c.id)) : new Set())
|
||||
}
|
||||
|
||||
const toggleOne = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const goPreview = () => {
|
||||
if (!parseResult) {
|
||||
setError(t('logs.nmea_error_no_file'))
|
||||
return
|
||||
}
|
||||
const generated = generateNmeaJournalCandidates({
|
||||
points: filteredPoints,
|
||||
mode,
|
||||
intervalMinutes,
|
||||
t
|
||||
}).candidates
|
||||
setSelectedIds(new Set(generated.map((c) => c.id)))
|
||||
setStep('preview')
|
||||
}
|
||||
|
||||
const applyImport = async () => {
|
||||
const picked = candidates.filter((c) => selectedIds.has(c.id)).map((c) => c.event)
|
||||
if (picked.length === 0) {
|
||||
setError(t('logs.nmea_error_no_selection'))
|
||||
return
|
||||
}
|
||||
const waypoints = importTrack ? nmeaPointsToWaypoints(filteredPoints) : undefined
|
||||
onImport(sortLogEventsByTime(picked), waypoints)
|
||||
if (pendingRaw) {
|
||||
try {
|
||||
await recordNmeaFileImport(logbookId, entryId, pendingRaw.filename, pendingRaw.text)
|
||||
} catch (err) {
|
||||
console.warn('NMEA import CRC record failed:', err)
|
||||
}
|
||||
}
|
||||
trackPlausibleEvent(PlausibleEvents.NMEA_IMPORTED, {
|
||||
mode,
|
||||
events: picked.length,
|
||||
track: importTrack && (waypoints?.length ?? 0) > 0
|
||||
})
|
||||
setStep('archive')
|
||||
}
|
||||
|
||||
const finishArchive = async (archive: boolean) => {
|
||||
try {
|
||||
if (archive && pendingRaw) {
|
||||
await saveNmeaArchive(logbookId, entryId, pendingRaw.filename, pendingRaw.text)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('NMEA archive save failed:', err)
|
||||
}
|
||||
handleClose()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') handleClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
const prevOverflow = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
document.body.style.overflow = prevOverflow
|
||||
}
|
||||
}, [open])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return createPortal(
|
||||
<div className="disclaimer-modal-overlay" onClick={handleClose}>
|
||||
<div className="disclaimer-modal-panel" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="auth-card glass registration-disclaimer registration-disclaimer--modal feedback-modal">
|
||||
<button
|
||||
type="button"
|
||||
className="registration-disclaimer__close feedback-modal__close"
|
||||
onClick={handleClose}
|
||||
aria-label={t('logs.nmea_cancel')}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
<div className="auth-header">
|
||||
<FileText className="auth-icon accent" size={40} />
|
||||
<h2>{t('logs.nmea_import_title')}</h2>
|
||||
</div>
|
||||
|
||||
{error && <div className="track-error-msg">{error}</div>}
|
||||
|
||||
{duplicateFile && (
|
||||
<div className="nmea-import-warning" role="status">
|
||||
{t('logs.nmea_warn_duplicate_file')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'config' && (
|
||||
<>
|
||||
<p className="registration-disclaimer__intro">{t('logs.nmea_import_intro')}</p>
|
||||
<label className="feedback-form__field">
|
||||
<span>{t('logs.nmea_file_label')}</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".nmea,.log,.txt"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) handleFile(file)
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{parseResult && (
|
||||
<div className="nmea-import-summary">
|
||||
<p>{t('logs.nmea_stats', {
|
||||
lines: parseResult.stats.parsedLines,
|
||||
types: parseResult.stats.sentenceTypes.join(', ')
|
||||
})}</p>
|
||||
{parseResult.warnings.includes('no_position') && (
|
||||
<p>{t('logs.nmea_warn_no_position')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<fieldset className="nmea-import-mode">
|
||||
<legend>{t('logs.nmea_mode_label')}</legend>
|
||||
<label><input type="radio" name="nmea-mode" checked={mode === 'interval'} onChange={() => setMode('interval')} /> {t('logs.nmea_mode_interval')}</label>
|
||||
<label><input type="radio" name="nmea-mode" checked={mode === 'change'} onChange={() => setMode('change')} /> {t('logs.nmea_mode_change')}</label>
|
||||
<label><input type="radio" name="nmea-mode" checked={mode === 'both'} onChange={() => setMode('both')} /> {t('logs.nmea_mode_both')}</label>
|
||||
</fieldset>
|
||||
|
||||
{(mode === 'interval' || mode === 'both') && (
|
||||
<label className="feedback-form__field">
|
||||
<span>{t('logs.nmea_interval_label')}</span>
|
||||
<select value={intervalMinutes} onChange={(e) => setIntervalMinutes(Number(e.target.value))}>
|
||||
<option value={30}>30 min</option>
|
||||
<option value={60}>60 min</option>
|
||||
<option value={90}>90 min</option>
|
||||
<option value={120}>120 min</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label className="nmea-import-checkbox">
|
||||
<input type="checkbox" checked={importTrack} onChange={(e) => setImportTrack(e.target.checked)} />
|
||||
{t('logs.nmea_import_track')}
|
||||
</label>
|
||||
|
||||
<div className="auth-actions feedback-form__actions">
|
||||
<button type="button" className="btn secondary" onClick={handleClose}>{t('logs.nmea_cancel')}</button>
|
||||
<button type="button" className="btn primary" onClick={goPreview} disabled={!parseResult}>
|
||||
{t('logs.nmea_preview')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'preview' && (
|
||||
<>
|
||||
<p>{t('logs.nmea_preview_hint', { count: candidates.length })}</p>
|
||||
<div className="nmea-preview-actions">
|
||||
<button type="button" className="btn secondary" onClick={() => toggleAll(true)}>{t('logs.nmea_select_all')}</button>
|
||||
<button type="button" className="btn secondary" onClick={() => toggleAll(false)}>{t('logs.nmea_select_none')}</button>
|
||||
</div>
|
||||
<div className="nmea-preview-list">
|
||||
{candidates.map((c) => (
|
||||
<label key={c.id} className="nmea-preview-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="nmea-preview-row__check"
|
||||
checked={selectedIds.has(c.id)}
|
||||
onChange={() => toggleOne(c.id)}
|
||||
/>
|
||||
<div className="nmea-preview-row__body">
|
||||
<div className="nmea-preview-row__meta">
|
||||
<span className="nmea-preview-time">{c.event.time}</span>
|
||||
<span className="nmea-preview-source">{t(`logs.nmea_source_${c.source}`)}</span>
|
||||
</div>
|
||||
<span className="nmea-preview-remarks">{c.event.remarks || c.event.mgk || '—'}</span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="auth-actions feedback-form__actions">
|
||||
<button type="button" className="btn secondary" onClick={() => setStep('config')}>{t('logs.nmea_back')}</button>
|
||||
<button type="button" className="btn primary" onClick={applyImport}>{t('logs.nmea_apply')}</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'archive' && (
|
||||
<>
|
||||
<p>{t('logs.nmea_archive_question')}</p>
|
||||
<div className="auth-actions feedback-form__actions">
|
||||
<button type="button" className="btn secondary" onClick={() => finishArchive(false)}>
|
||||
{t('logs.nmea_archive_discard')}
|
||||
</button>
|
||||
<button type="button" className="btn primary" onClick={() => finishArchive(true)}>
|
||||
{t('logs.nmea_archive_keep')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Fingerprint, Loader2, AlertTriangle } from 'lucide-react'
|
||||
import type { PasskeySignature } from '../types/signatures.js'
|
||||
import { formatAppDateTime } from '../utils/dateTimeFormat.js'
|
||||
|
||||
interface PasskeySignButtonProps {
|
||||
label: string
|
||||
@@ -42,9 +43,7 @@ export default function PasskeySignButton({
|
||||
}
|
||||
}
|
||||
|
||||
const formattedDate = signature
|
||||
? new Date(signature.signedAt).toLocaleString(i18n.language === 'de' ? 'de-DE' : 'en-GB')
|
||||
: ''
|
||||
const formattedDate = signature ? formatAppDateTime(signature.signedAt, i18n.language) : ''
|
||||
|
||||
return (
|
||||
<div className="passkey-sign-block">
|
||||
|
||||
@@ -3,9 +3,9 @@ import { useTranslation } from 'react-i18next'
|
||||
import { db } from '../services/db.js'
|
||||
import { getActiveMasterKey } from '../services/auth.js'
|
||||
import { getLogbookKey } from '../services/logbookKeys.js'
|
||||
import { encryptJson, decryptJson } from '../services/crypto.js'
|
||||
import { syncLogbook } from '../services/sync.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { decryptJson } from '../services/crypto.js'
|
||||
import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.js'
|
||||
import { fileToCompressedJpegDataUrl } from '../utils/imageCompress.js'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import { Camera, Trash2 } from 'lucide-react'
|
||||
@@ -90,109 +90,30 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
|
||||
setUploading(true)
|
||||
setError(null)
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
const img = new Image()
|
||||
img.onload = async () => {
|
||||
try {
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) throw new Error('Could not get canvas context')
|
||||
|
||||
let width = img.width
|
||||
let height = img.height
|
||||
const MAX_WIDTH = 1280
|
||||
const MAX_HEIGHT = 720
|
||||
|
||||
// Calculate resizing conserving aspect ratio
|
||||
if (width > MAX_WIDTH || height > MAX_HEIGHT) {
|
||||
const ratio = Math.min(MAX_WIDTH / width, MAX_HEIGHT / height)
|
||||
width = Math.round(width * ratio)
|
||||
height = Math.round(height * ratio)
|
||||
}
|
||||
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
ctx.drawImage(img, 0, 0, width, height)
|
||||
|
||||
// Compress to JPEG, 70% quality
|
||||
const compressedBase64 = canvas.toDataURL('image/jpeg', 0.7)
|
||||
|
||||
// Encrypt
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
|
||||
const photoId = window.crypto.randomUUID()
|
||||
const photoPayload = {
|
||||
image: compressedBase64,
|
||||
caption: caption.trim()
|
||||
}
|
||||
|
||||
const encrypted = await encryptJson(photoPayload, masterKey)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// Store locally
|
||||
await db.photos.put({
|
||||
payloadId: photoId,
|
||||
entryId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
caption: '', // stored encrypted inside payload
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
// Queue for background sync
|
||||
await db.syncQueue.put({
|
||||
action: 'create',
|
||||
type: 'photo',
|
||||
payloadId: photoId,
|
||||
logbookId,
|
||||
data: JSON.stringify({
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
entryId
|
||||
}),
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
setCaption('')
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: 'logbook' })
|
||||
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
} catch (err: any) {
|
||||
console.error('Failed to process image:', err)
|
||||
setError(err.message || 'Failed to process image')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
img.src = event.target?.result as string
|
||||
try {
|
||||
const compressedBase64 = await fileToCompressedJpegDataUrl(file)
|
||||
await saveEntryPhoto({
|
||||
logbookId,
|
||||
entryId,
|
||||
imageDataUrl: compressedBase64,
|
||||
caption: caption.trim(),
|
||||
analyticsContext: 'logbook'
|
||||
})
|
||||
setCaption('')
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to process image:', err)
|
||||
setError(err instanceof Error ? err.message : 'Failed to process image')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
const handleDelete = async (photoId: string) => {
|
||||
if (await showConfirm(t('logs.photo_delete_confirm'), t('logs.photos_title'), t('logs.confirm_yes'), t('logs.confirm_no'))) {
|
||||
try {
|
||||
const now = new Date().toISOString()
|
||||
|
||||
await db.photos.delete(photoId)
|
||||
|
||||
await db.syncQueue.put({
|
||||
action: 'delete',
|
||||
type: 'photo',
|
||||
payloadId: photoId,
|
||||
logbookId,
|
||||
data: '',
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
} catch (err: any) {
|
||||
await deleteEntryPhoto(logbookId, photoId)
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to delete photo:', err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { User } from 'lucide-react'
|
||||
|
||||
interface ProfileHeaderButtonProps {
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
export default function ProfileHeaderButton({ onClick }: ProfileHeaderButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
const username = localStorage.getItem('active_username') || 'Skipper'
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon skipper-badge"
|
||||
onClick={onClick}
|
||||
title={t('dashboard.open_profile', { name: username })}
|
||||
aria-label={t('dashboard.open_profile', { name: username })}
|
||||
data-tour="nav-profile"
|
||||
>
|
||||
<User size={18} aria-hidden="true" />
|
||||
<span className="skipper-badge__name">{username}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -56,7 +56,7 @@ export default function PushNotificationSettings() {
|
||||
trackPlausibleEvent(PlausibleEvents.PUSH_DISABLED)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : t('settings.push_error')
|
||||
const message = err instanceof Error ? err.message : t('profile.push_error')
|
||||
showAlert(message)
|
||||
void loadPrefs()
|
||||
} finally {
|
||||
@@ -69,10 +69,10 @@ export default function PushNotificationSettings() {
|
||||
<div className="member-editor-card glass mt-4">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
|
||||
<BellOff size={20} style={{ color: '#94a3b8' }} />
|
||||
<h3 style={{ margin: 0, color: '#94a3b8', fontSize: '16px' }}>{t('settings.push_title')}</h3>
|
||||
<h3 style={{ margin: 0, color: '#94a3b8', fontSize: '16px' }}>{t('profile.push_title')}</h3>
|
||||
</div>
|
||||
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: 0 }}>
|
||||
{t('settings.push_unsupported')}
|
||||
{t('profile.push_unsupported')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
@@ -83,23 +83,23 @@ export default function PushNotificationSettings() {
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
|
||||
<Bell size={20} style={{ color: 'var(--app-accent-light)' }} />
|
||||
<h3 style={{ margin: 0, color: 'var(--app-accent-light)', fontSize: '16px' }}>
|
||||
{t('settings.push_title')}
|
||||
{t('profile.push_title')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: '0 0 16px 0' }}>
|
||||
{t('settings.push_desc')}
|
||||
{t('profile.push_desc')}
|
||||
</p>
|
||||
|
||||
{iosNeedsInstall && (
|
||||
<p className="text-muted" style={{ fontSize: '13px', margin: '0 0 12px 0' }}>
|
||||
{t('settings.push_ios_install_hint')}
|
||||
{t('profile.push_ios_install_hint')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{permission === 'denied' && (
|
||||
<p style={{ fontSize: '13px', color: '#f87171', margin: '0 0 12px 0' }}>
|
||||
{t('settings.push_denied_hint')}
|
||||
{t('profile.push_denied_hint')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -122,12 +122,12 @@ export default function PushNotificationSettings() {
|
||||
disabled={loading || toggling || iosNeedsInstall}
|
||||
style={{ width: '18px', height: '18px', cursor: 'inherit' }}
|
||||
/>
|
||||
<span>{t('settings.push_enable')}</span>
|
||||
<span>{t('profile.push_enable')}</span>
|
||||
</label>
|
||||
|
||||
{enabled && permission === 'granted' && (
|
||||
<p className="text-muted" style={{ fontSize: '12px', margin: '12px 0 0 0' }}>
|
||||
{t('settings.push_active')}
|
||||
{t('profile.push_active')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cycleAppLanguage, getNextLanguage, isGermanLocale } from '../utils/i18nLanguages.js'
|
||||
import { decryptJson } from '../services/crypto.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import VesselForm from './VesselForm.tsx'
|
||||
@@ -48,9 +49,9 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
||||
const res = await fetch(`/api/collaboration/share-pull?token=${token}`)
|
||||
if (!res.ok) {
|
||||
if (res.status === 410) {
|
||||
throw new Error(i18n.language.startsWith('de') ? 'Dieser Freigabelink ist abgelaufen.' : 'This share link has expired.')
|
||||
throw new Error(isGermanLocale(i18n.language) ? 'Dieser Freigabelink ist abgelaufen.' : 'This share link has expired.')
|
||||
}
|
||||
throw new Error(i18n.language.startsWith('de') ? 'Fehler beim Laden des freigegebenen Logbuchs.' : 'Failed to fetch shared logbook data.')
|
||||
throw new Error(isGermanLocale(i18n.language) ? 'Fehler beim Laden des freigegebenen Logbuchs.' : 'Failed to fetch shared logbook data.')
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
@@ -136,15 +137,14 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
||||
}
|
||||
|
||||
const toggleLanguage = () => {
|
||||
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
|
||||
i18n.changeLanguage(nextLang)
|
||||
cycleAppLanguage(i18n)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="tab-placeholder" style={{ height: '100vh', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center' }}>
|
||||
<Ship className="header-logo spin" size={48} />
|
||||
<p>{i18n.language.startsWith('de') ? 'Lade freigegebenes Logbuch...' : 'Loading shared logbook...'}</p>
|
||||
<p>{isGermanLocale(i18n.language) ? 'Lade freigegebenes Logbuch...' : 'Loading shared logbook...'}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -153,10 +153,10 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
||||
return (
|
||||
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', padding: '20px', textAlign: 'center' }}>
|
||||
<AlertCircle size={48} style={{ color: '#ef4444', marginBottom: '16px' }} />
|
||||
<h2 style={{ color: '#f1f5f9', marginBottom: '8px' }}>{i18n.language.startsWith('de') ? 'Verbindungsfehler' : 'Access Error'}</h2>
|
||||
<h2 style={{ color: '#f1f5f9', marginBottom: '8px' }}>{isGermanLocale(i18n.language) ? 'Verbindungsfehler' : 'Access Error'}</h2>
|
||||
<p style={{ color: '#94a3b8', maxWidth: '400px', marginBottom: '24px' }}>{error}</p>
|
||||
<button className="btn primary" onClick={loadData} style={{ width: 'auto' }}>
|
||||
{i18n.language.startsWith('de') ? 'Erneut versuchen' : 'Retry'}
|
||||
{isGermanLocale(i18n.language) ? 'Erneut versuchen' : 'Retry'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
@@ -173,7 +173,7 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
||||
<h2>{logbookTitle}</h2>
|
||||
<p className="app-subtitle" style={{ color: '#10b981', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<Lock size={12} />
|
||||
<span>{i18n.language.startsWith('de') ? 'Schreibgeschützte Ansicht (Ende-zu-Ende verschlüsselt)' : 'Read-Only View (End-to-End Encrypted)'}</span>
|
||||
<span>{isGermanLocale(i18n.language) ? 'Schreibgeschützte Ansicht (Ende-zu-Ende verschlüsselt)' : 'Read-Only View (End-to-End Encrypted)'}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -181,7 +181,7 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
||||
<div className="header-actions">
|
||||
<button className="btn secondary" onClick={toggleLanguage} style={{ width: 'auto', padding: '6px 12px', fontSize: '13px' }}>
|
||||
<Globe size={14} style={{ marginRight: '4px' }} />
|
||||
{i18n.language.startsWith('de') ? 'English' : 'Deutsch'}
|
||||
{t(`languages.${getNextLanguage(i18n.language)}`)}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Settings as SettingsIcon, Save, Check, Users, Trash2, Copy, Link as LinkIcon, Compass } from 'lucide-react'
|
||||
import { Settings as SettingsIcon, Check, Users, Trash2, Copy, Link as LinkIcon } from 'lucide-react'
|
||||
import { ensureLogbookKey } from '../services/logbookKeys.js'
|
||||
import LogbookBackupPanel from './LogbookBackupPanel.tsx'
|
||||
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
|
||||
import PushNotificationSettings from './PushNotificationSettings.tsx'
|
||||
import LinkQrCode from './LinkQrCode.tsx'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import { notifyAppearanceChanged } from '../services/appearance.js'
|
||||
import ThemedSelect from './ThemedSelect.tsx'
|
||||
import { useAppTour } from '../context/AppTourContext.tsx'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { apiFetch } from '../services/api.js'
|
||||
import {
|
||||
enableCollaboratorChangePush,
|
||||
isCollaboratorPushActive,
|
||||
isPushSupported
|
||||
} from '../services/pushNotifications.js'
|
||||
import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
|
||||
|
||||
interface SettingsFormProps {
|
||||
logbookId?: string | null
|
||||
@@ -25,7 +27,6 @@ interface Collaborator {
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
// Convert ArrayBuffer to Hex String for URL fragment
|
||||
const bufferToHex = (buffer: ArrayBuffer): string => {
|
||||
return Array.from(new Uint8Array(buffer))
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
@@ -35,14 +36,7 @@ const bufferToHex = (buffer: ArrayBuffer): string => {
|
||||
export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const { showConfirm, showAlert } = useDialog()
|
||||
const { restartTour } = useAppTour()
|
||||
const [apiKey, setApiKey] = useState(localStorage.getItem('owm_api_key') || '')
|
||||
const [theme, setTheme] = useState(localStorage.getItem('active_theme') || 'auto')
|
||||
const [colorScheme, setColorScheme] = useState(localStorage.getItem('active_color_scheme') || 'auto')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
// Collaboration States
|
||||
const [collaborators, setCollaborators] = useState<Collaborator[]>([])
|
||||
const [isOwner, setIsOwner] = useState(true)
|
||||
const [inviteLink, setInviteLink] = useState('')
|
||||
@@ -51,7 +45,6 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
||||
const [collabError, setCollabError] = useState<string | null>(null)
|
||||
const [loadingCollabs, setLoadingCollabs] = useState(false)
|
||||
|
||||
// Public Share Link States
|
||||
const [shareEnabled, setShareEnabled] = useState(false)
|
||||
const [shareLink, setShareLink] = useState('')
|
||||
const [shareCopied, setShareCopied] = useState(false)
|
||||
@@ -120,9 +113,9 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
||||
} else {
|
||||
throw new Error('Failed to toggle public share link.')
|
||||
}
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
console.error('Toggle share link failed:', err)
|
||||
showAlert(err.message || 'Failed to update public share link.')
|
||||
showAlert(err instanceof Error ? err.message : 'Failed to update public share link.')
|
||||
} finally {
|
||||
setLoadingShareLink(false)
|
||||
}
|
||||
@@ -136,7 +129,6 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const loadCollaborators = async () => {
|
||||
setLoadingCollabs(true)
|
||||
setCollabError(null)
|
||||
@@ -166,6 +158,43 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
||||
}
|
||||
}
|
||||
|
||||
const promptPushAfterInviteCreated = async () => {
|
||||
if (!isPushSupported()) return
|
||||
if (await isCollaboratorPushActive()) return
|
||||
|
||||
const iosNeedsInstall = isIosDevice() && !isRunningStandalone()
|
||||
|
||||
if (iosNeedsInstall) {
|
||||
await showAlert(
|
||||
t('settings.invite_push_prompt_ios_message'),
|
||||
t('settings.invite_push_prompt_title'),
|
||||
t('settings.invite_push_prompt_later')
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const enable = await showConfirm(
|
||||
t('settings.invite_push_prompt_message'),
|
||||
t('settings.invite_push_prompt_title'),
|
||||
t('settings.invite_push_prompt_enable'),
|
||||
t('settings.invite_push_prompt_later')
|
||||
)
|
||||
|
||||
if (!enable) return
|
||||
|
||||
try {
|
||||
await enableCollaboratorChangePush()
|
||||
await showAlert(
|
||||
t('settings.invite_push_prompt_success'),
|
||||
t('settings.invite_push_prompt_title')
|
||||
)
|
||||
trackPlausibleEvent(PlausibleEvents.PUSH_ENABLED)
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to enable push after invite:', err)
|
||||
await showAlert(err instanceof Error ? err.message : t('profile.push_error'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenerateInvite = async () => {
|
||||
if (!logbookId) return
|
||||
setGeneratingInvite(true)
|
||||
@@ -173,10 +202,8 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
||||
if (!localStorage.getItem('active_userid')) return
|
||||
|
||||
try {
|
||||
// 1. Ensure logbook has an E2E key (upgrades legacy logbooks if needed)
|
||||
const logbookKey = await ensureLogbookKey(logbookId)
|
||||
|
||||
// 2. Create invite token on server
|
||||
const res = await apiFetch('/api/collaboration/invite', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ logbookId, role: 'WRITE' })
|
||||
@@ -187,16 +214,15 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
||||
}
|
||||
|
||||
const invite = await res.json()
|
||||
|
||||
// 3. Format link containing token (URL params) and key (URL hash anchor)
|
||||
const hexKey = bufferToHex(logbookKey)
|
||||
const link = `${window.location.origin}/invite?token=${invite.token}#key=${hexKey}`
|
||||
|
||||
|
||||
setInviteLink(link)
|
||||
trackPlausibleEvent(PlausibleEvents.INVITE_GENERATED)
|
||||
} catch (err: any) {
|
||||
await promptPushAfterInviteCreated()
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to generate invite:', err)
|
||||
showAlert(err.message || 'Failed to generate invite link.')
|
||||
showAlert(err instanceof Error ? err.message : 'Failed to generate invite link.')
|
||||
} finally {
|
||||
setGeneratingInvite(false)
|
||||
}
|
||||
@@ -225,40 +251,26 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
||||
} else {
|
||||
throw new Error('Failed to revoke collaborator access.')
|
||||
}
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
console.error('Revocation failed:', err)
|
||||
showAlert(err.message || 'Failed to revoke access.')
|
||||
showAlert(err instanceof Error ? err.message : 'Failed to revoke access.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const persistAppearance = (nextTheme: string, nextColorScheme: string) => {
|
||||
localStorage.setItem('active_theme', nextTheme)
|
||||
localStorage.setItem('active_color_scheme', nextColorScheme)
|
||||
notifyAppearanceChanged()
|
||||
}
|
||||
|
||||
const handleThemeChange = (nextTheme: string) => {
|
||||
setTheme(nextTheme)
|
||||
persistAppearance(nextTheme, colorScheme)
|
||||
}
|
||||
|
||||
const handleColorSchemeChange = (nextColorScheme: string) => {
|
||||
setColorScheme(nextColorScheme)
|
||||
persistAppearance(theme, nextColorScheme)
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
setSuccess(false)
|
||||
|
||||
localStorage.setItem('owm_api_key', apiKey.trim())
|
||||
persistAppearance(theme, colorScheme)
|
||||
|
||||
setSaving(false)
|
||||
setSuccess(true)
|
||||
setTimeout(() => setSuccess(false), 3000)
|
||||
if (!logbookId) {
|
||||
return (
|
||||
<div className="form-card">
|
||||
<div className="form-header">
|
||||
<SettingsIcon size={24} className="form-icon" />
|
||||
<div>
|
||||
<h2>{t('settings.title')}</h2>
|
||||
<p className="form-subtitle">{t('settings.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted mt-4">{t('settings.select_logbook_hint')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -267,128 +279,12 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
||||
<SettingsIcon size={24} className="form-icon" />
|
||||
<div>
|
||||
<h2>{t('settings.title')}</h2>
|
||||
<p className="form-subtitle" style={{ margin: '4px 0 0 0', fontSize: '13px', color: '#94a3b8' }}>
|
||||
{t('settings.subtitle')}
|
||||
</p>
|
||||
<p className="form-subtitle">{t('settings.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="vessel-form mt-6">
|
||||
<PwaInstallPrompt variant="inline" />
|
||||
<PushNotificationSettings />
|
||||
|
||||
{/* Weather Integration card */}
|
||||
<div className="member-editor-card glass">
|
||||
<h3 style={{ marginTop: 0, marginBottom: '12px', color: '#fbbf24', fontSize: '16px' }}>
|
||||
{t('settings.owm_title')}
|
||||
</h3>
|
||||
<p style={{ fontSize: '13.5px', color: '#94a3b8', lineHeight: '145%', margin: '0 0 16px 0' }}>
|
||||
{t('settings.key_help')}
|
||||
</p>
|
||||
|
||||
<div className="input-group">
|
||||
<label htmlFor="owm-api-key" style={{ display: 'block', fontSize: '13.5px', color: '#94a3b8', marginBottom: '6px', fontWeight: 500 }}>
|
||||
{t('settings.owm_key')}
|
||||
</label>
|
||||
<input
|
||||
id="owm-api-key"
|
||||
name="owm-api-key"
|
||||
type="password"
|
||||
className="input-text"
|
||||
placeholder="e.g. 8b6a7f...d8"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
disabled={saving}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Theme customization card */}
|
||||
<div className="member-editor-card glass mt-4">
|
||||
<h3 style={{ marginTop: 0, marginBottom: '12px', color: '#fbbf24', fontSize: '16px' }}>
|
||||
{t('settings.theme_title')}
|
||||
</h3>
|
||||
<p style={{ fontSize: '13.5px', color: '#94a3b8', lineHeight: '145%', margin: '0 0 16px 0' }}>
|
||||
{t('settings.theme_label')}
|
||||
</p>
|
||||
|
||||
<div className="input-group">
|
||||
<ThemedSelect
|
||||
id="app-theme"
|
||||
value={theme}
|
||||
disabled={saving}
|
||||
onChange={handleThemeChange}
|
||||
options={[
|
||||
{ value: 'auto', label: t('settings.theme_auto') },
|
||||
{ value: 'ocean', label: t('settings.theme_ocean') },
|
||||
{ value: 'material', label: t('settings.theme_material') },
|
||||
{ value: 'cupertino', label: t('settings.theme_cupertino') }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="member-editor-card glass mt-4">
|
||||
<h3 style={{ marginTop: 0, marginBottom: '12px', color: 'var(--app-accent-light)', fontSize: '16px' }}>
|
||||
{t('settings.color_scheme_title')}
|
||||
</h3>
|
||||
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: '0 0 16px 0' }}>
|
||||
{t('settings.color_scheme_label')}
|
||||
</p>
|
||||
|
||||
<div className="input-group">
|
||||
<ThemedSelect
|
||||
id="app-color-scheme"
|
||||
value={colorScheme}
|
||||
disabled={saving}
|
||||
onChange={handleColorSchemeChange}
|
||||
options={[
|
||||
{ value: 'auto', label: t('settings.color_scheme_auto') },
|
||||
{ value: 'light', label: t('settings.color_scheme_light') },
|
||||
{ value: 'dark', label: t('settings.color_scheme_dark') }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="member-editor-card glass mt-4">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
|
||||
<Compass size={20} style={{ color: 'var(--app-accent-light)' }} />
|
||||
<h3 style={{ margin: 0, color: 'var(--app-accent-light)', fontSize: '16px' }}>
|
||||
{t('settings.tour_title')}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: '0 0 16px 0' }}>
|
||||
{t('settings.tour_desc')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => restartTour()}
|
||||
>
|
||||
{t('settings.tour_restart')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="form-actions mt-4 mb-6">
|
||||
{success && (
|
||||
<div className="success-toast">
|
||||
<Check size={16} />
|
||||
<span>{t('settings.saved')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="submit" className="btn primary" disabled={saving}>
|
||||
<Save size={18} />
|
||||
{saving ? t('settings.saving') : t('settings.save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Public Share Link Card (Only visible to Logbook Owner) */}
|
||||
{logbookId && isOwner && (
|
||||
<div className="member-editor-card glass mt-6" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '24px' }}>
|
||||
<div className="member-editor-card glass mt-6">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
|
||||
<LinkIcon size={20} style={{ color: '#fbbf24' }} />
|
||||
<h3 style={{ margin: 0, color: '#fbbf24', fontSize: '16px' }}>
|
||||
@@ -419,34 +315,36 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
||||
</div>
|
||||
|
||||
{shareEnabled && shareLink && (
|
||||
<div className="input-group mb-4 copy-link-row">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={shareLink}
|
||||
className="input-text font-mono text-xs"
|
||||
style={{ flex: 1, padding: '10px' }}
|
||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={handleCopyShareLink}
|
||||
style={{ width: 'auto', padding: '10px' }}
|
||||
>
|
||||
{shareCopied ? <Check size={16} /> : <Copy size={16} />}
|
||||
</button>
|
||||
<div className="link-with-qr mb-4">
|
||||
<div className="input-group copy-link-row">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={shareLink}
|
||||
className="input-text font-mono text-xs"
|
||||
style={{ flex: 1, padding: '10px' }}
|
||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={handleCopyShareLink}
|
||||
style={{ width: 'auto', padding: '10px' }}
|
||||
title={t('settings.share_copy_btn')}
|
||||
>
|
||||
{shareCopied ? <Check size={16} /> : <Copy size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
<LinkQrCode value={shareLink} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Backup & Restore (owner only) */}
|
||||
{logbookId && isOwner && (
|
||||
<LogbookBackupPanel logbookId={logbookId} onRestored={onLogbookRestored} />
|
||||
)}
|
||||
|
||||
{/* Crew Collaboration Card (Only visible to Logbook Owner) */}
|
||||
{logbookId && isOwner && (
|
||||
<div className="member-editor-card glass mt-6" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '24px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
|
||||
@@ -474,27 +372,30 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
||||
</div>
|
||||
|
||||
{inviteLink && (
|
||||
<div className="input-group mb-6 copy-link-row">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={inviteLink}
|
||||
className="input-text font-mono text-xs"
|
||||
style={{ flex: 1, padding: '10px' }}
|
||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={handleCopyInvite}
|
||||
style={{ width: 'auto', padding: '10px' }}
|
||||
>
|
||||
{inviteCopied ? <Check size={16} /> : <Copy size={16} />}
|
||||
</button>
|
||||
<div className="link-with-qr mb-6">
|
||||
<div className="input-group copy-link-row">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={inviteLink}
|
||||
className="input-text font-mono text-xs"
|
||||
style={{ flex: 1, padding: '10px' }}
|
||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={handleCopyInvite}
|
||||
style={{ width: 'auto', padding: '10px' }}
|
||||
title={t('settings.share_copy_btn')}
|
||||
>
|
||||
{inviteCopied ? <Check size={16} /> : <Copy size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
<LinkQrCode value={inviteLink} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collaborator List */}
|
||||
<h4 style={{ color: '#fbbf24', fontSize: '14px', marginBottom: '12px' }}>
|
||||
{t('logs.collaborators_list')}
|
||||
</h4>
|
||||
|
||||
@@ -5,6 +5,7 @@ import SignaturePad from './SignaturePad.tsx'
|
||||
import PasskeySignButton from './PasskeySignButton.tsx'
|
||||
import type { PasskeySignature, SignatureValue } from '../types/signatures.js'
|
||||
import { isPasskeySignature, getSignaturePayload, getSignatureAttribution } from '../utils/signatures.js'
|
||||
import { formatAppDateTime } from '../utils/dateTimeFormat.js'
|
||||
|
||||
type SignatureMode = 'passkey' | 'classic'
|
||||
|
||||
@@ -30,9 +31,7 @@ function SignerAttributionBadge({ value }: { value: SignatureValue | '' }) {
|
||||
const attribution = getSignatureAttribution(value)
|
||||
if (!attribution) return null
|
||||
|
||||
const formattedDate = new Date(attribution.signedAt).toLocaleString(
|
||||
i18n.language === 'de' ? 'de-DE' : 'en-GB'
|
||||
)
|
||||
const formattedDate = formatAppDateTime(attribution.signedAt, i18n.language)
|
||||
|
||||
return (
|
||||
<div className="passkey-sign-badge valid signature-attribution-badge">
|
||||
|
||||
@@ -14,6 +14,11 @@ import {
|
||||
} from '../services/statsAggregation.js'
|
||||
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
|
||||
import { formatFuelPerMotorHour } from '../utils/fuelStats.js'
|
||||
import {
|
||||
loadLogbookEventSeries,
|
||||
type EventSeriesPoint,
|
||||
type EventSeriesSummary
|
||||
} from '../services/eventSeriesAggregation.js'
|
||||
|
||||
interface StatsDashboardProps {
|
||||
logbookId: string
|
||||
@@ -217,7 +222,62 @@ function PropulsionBreakdown({ totals }: { totals: StatsTotals }) {
|
||||
)
|
||||
}
|
||||
|
||||
function LogbookScopeView({ summary }: { summary: LogbookStatsSummary }) {
|
||||
function EventSeriesList({ title, points, emptyLabel }: { title: string; points: EventSeriesPoint[]; emptyLabel: string }) {
|
||||
if (points.length === 0) {
|
||||
return (
|
||||
<div className="stats-event-series-block">
|
||||
<h4 className="stats-section-subtitle">{title}</h4>
|
||||
<p className="stats-section-sub">{emptyLabel}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stats-event-series-block">
|
||||
<h4 className="stats-section-subtitle">{title}</h4>
|
||||
<ul className="stats-event-series-list">
|
||||
{points.map((point, idx) => (
|
||||
<li key={`${point.entryId}-${point.time}-${idx}`} className="stats-event-series-item">
|
||||
<span className="stats-event-series-when">
|
||||
{new Date(point.date).toLocaleDateString(undefined, { day: '2-digit', month: '2-digit' })}
|
||||
{' · '}
|
||||
{point.time}
|
||||
</span>
|
||||
<span className="stats-event-series-value">{point.summary}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EventSeriesPanel({ series }: { series: EventSeriesSummary }) {
|
||||
const { t } = useTranslation()
|
||||
const motorPoints = series.motor.map((point) => ({
|
||||
...point,
|
||||
summary: point.summary === 'start'
|
||||
? t('logs.live_motor_start')
|
||||
: t('logs.live_motor_stop')
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="member-editor-card glass mt-6">
|
||||
<h3 className="stats-section-title">{t('stats.event_series_title')}</h3>
|
||||
<p className="stats-section-sub">{t('stats.event_series_hint')}</p>
|
||||
<EventSeriesList title={t('stats.event_series_pressure')} points={series.pressure} emptyLabel={t('stats.event_series_empty')} />
|
||||
<EventSeriesList title={t('stats.event_series_wind')} points={series.wind} emptyLabel={t('stats.event_series_empty')} />
|
||||
<EventSeriesList title={t('stats.event_series_motor')} points={motorPoints} emptyLabel={t('stats.event_series_empty')} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LogbookScopeView({
|
||||
summary,
|
||||
eventSeries
|
||||
}: {
|
||||
summary: LogbookStatsSummary
|
||||
eventSeries: EventSeriesSummary | null
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { travelDays, routePorts, trackSegments, totals } = summary
|
||||
|
||||
@@ -313,6 +373,8 @@ function LogbookScopeView({ summary }: { summary: LogbookStatsSummary }) {
|
||||
<h3 className="stats-section-title">{t('stats.propulsion_title')}</h3>
|
||||
<PropulsionBreakdown totals={totals} />
|
||||
</div>
|
||||
|
||||
{eventSeries && <EventSeriesPanel series={eventSeries} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -323,18 +385,21 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [logbookStats, setLogbookStats] = useState<LogbookStatsSummary | null>(null)
|
||||
const [eventSeries, setEventSeries] = useState<EventSeriesSummary | null>(null)
|
||||
const [accountStats, setAccountStats] = useState<Awaited<ReturnType<typeof loadAccountStats>> | null>(null)
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [lb, acc] = await Promise.all([
|
||||
const [lb, acc, series] = await Promise.all([
|
||||
loadLogbookStats(logbookId, logbookTitle, true),
|
||||
loadAccountStats(false)
|
||||
loadAccountStats(false),
|
||||
loadLogbookEventSeries(logbookId)
|
||||
])
|
||||
setLogbookStats(lb)
|
||||
setAccountStats(acc)
|
||||
setEventSeries(series)
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to load statistics:', err)
|
||||
setError(err instanceof Error ? err.message : 'Failed to load statistics.')
|
||||
@@ -397,7 +462,7 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
|
||||
<p>{t('stats.loading')}</p>
|
||||
</div>
|
||||
) : scope === 'logbook' && logbookStats ? (
|
||||
<LogbookScopeView summary={logbookStats} />
|
||||
<LogbookScopeView summary={logbookStats} eventSeries={eventSeries} />
|
||||
) : scope === 'account' && accountStats ? (
|
||||
<>
|
||||
<TotalsGrid totals={accountStats.totals} />
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { clampTankLiters } from '../utils/tankCapacity.js'
|
||||
|
||||
interface TankLiterInputProps {
|
||||
id?: string
|
||||
label: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
maxLiters?: number
|
||||
disabled?: boolean
|
||||
titleTooltip?: string
|
||||
}
|
||||
|
||||
function parseInputLiters(value: string): number {
|
||||
const trimmed = value.trim().replace(',', '.')
|
||||
if (!trimmed) return 0
|
||||
const parsed = Number(trimmed)
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
|
||||
export default function TankLiterInput({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
maxLiters,
|
||||
disabled = false,
|
||||
titleTooltip
|
||||
}: TankLiterInputProps) {
|
||||
const { t } = useTranslation()
|
||||
const useSlider = maxLiters != null && maxLiters > 0
|
||||
|
||||
const emitValue = useCallback(
|
||||
(liters: number) => {
|
||||
const clamped = clampTankLiters(liters, useSlider ? maxLiters : undefined)
|
||||
const str =
|
||||
Number.isInteger(clamped) ? String(clamped) : String(Number(clamped.toFixed(1)))
|
||||
onChange(str)
|
||||
},
|
||||
[onChange, maxLiters, useSlider]
|
||||
)
|
||||
|
||||
const handleNumberChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(e.target.value)
|
||||
}
|
||||
|
||||
const handleNumberBlur = () => {
|
||||
if (!useSlider) return
|
||||
emitValue(parseInputLiters(value))
|
||||
}
|
||||
|
||||
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
emitValue(Number(e.target.value))
|
||||
}
|
||||
|
||||
const numericValue = parseInputLiters(value)
|
||||
const sliderValue = useSlider ? clampTankLiters(numericValue, maxLiters) : 0
|
||||
|
||||
return (
|
||||
<div className="input-group tank-liter-input">
|
||||
<label htmlFor={id} title={titleTooltip}>{label}</label>
|
||||
{useSlider && (
|
||||
<>
|
||||
<input
|
||||
type="range"
|
||||
className="tank-liter-slider"
|
||||
min={0}
|
||||
max={maxLiters}
|
||||
step={1}
|
||||
value={sliderValue}
|
||||
onChange={handleSliderChange}
|
||||
disabled={disabled}
|
||||
title={titleTooltip}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={maxLiters}
|
||||
aria-valuenow={sliderValue}
|
||||
aria-label={label}
|
||||
/>
|
||||
<div className="tank-liter-slider-hint" aria-hidden="true">
|
||||
{t('logs.tank_slider_of_max', {
|
||||
current: sliderValue,
|
||||
max: maxLiters
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
id={id}
|
||||
type="number"
|
||||
className="input-text"
|
||||
value={value}
|
||||
onChange={handleNumberChange}
|
||||
onBlur={handleNumberBlur}
|
||||
disabled={disabled}
|
||||
min={0}
|
||||
max={useSlider ? maxLiters : undefined}
|
||||
step="any"
|
||||
title={titleTooltip}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import { useSyncIndicator } from '../hooks/useSyncIndicator.js'
|
||||
import {
|
||||
User,
|
||||
ChevronLeft,
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
CircleAlert
|
||||
} from 'lucide-react'
|
||||
import AccountDangerZone from './AccountDangerZone.tsx'
|
||||
import UserProfilePreferences from './UserProfilePreferences.tsx'
|
||||
import BetaBadge from './BetaBadge.tsx'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import {
|
||||
@@ -127,7 +129,12 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
||||
const [pendingRecoveryPhrase, setPendingRecoveryPhrase] = useState<string | null>(null)
|
||||
const [recoveryCopied, setRecoveryCopied] = useState(false)
|
||||
|
||||
const pendingSyncCount = useLiveQuery(() => db.syncQueue.count()) ?? 0
|
||||
const {
|
||||
pendingCount: pendingSyncCount,
|
||||
showSpinner,
|
||||
showPendingWarning,
|
||||
connStatusClassName
|
||||
} = useSyncIndicator()
|
||||
|
||||
const sharedLogbookCount = useLiveQuery(
|
||||
() => db.logbooks.filter((lb) => lb.isShared === 1).count(),
|
||||
@@ -436,6 +443,7 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
||||
</section>
|
||||
) : profile ? (
|
||||
<>
|
||||
<div data-tour="profile-preferences">
|
||||
<section className="form-card">
|
||||
<div className="form-header">
|
||||
<User size={24} className="form-icon" />
|
||||
@@ -476,6 +484,9 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<UserProfilePreferences userId={profile.userId} />
|
||||
</div>
|
||||
|
||||
<section className="member-editor-card glass">
|
||||
<div className="profile-section-header">
|
||||
<Shield size={20} />
|
||||
@@ -524,11 +535,16 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
||||
<h3>{t('profile.device_title')}</h3>
|
||||
</div>
|
||||
<p className="profile-section-desc">{t('profile.device_desc')}</p>
|
||||
<div className={`profile-device-status conn-status ${online ? (pendingSyncCount > 0 ? 'warning' : 'online') : 'offline'}`}>
|
||||
<div className={`profile-device-status ${connStatusClassName(online)}`}>
|
||||
{online ? (
|
||||
pendingSyncCount > 0 ? (
|
||||
showSpinner ? (
|
||||
<>
|
||||
<RefreshCw size={16} className="spin" aria-hidden="true" />
|
||||
<span>{t('sync.status_syncing')}</span>
|
||||
</>
|
||||
) : showPendingWarning ? (
|
||||
<>
|
||||
<RefreshCw size={16} aria-hidden="true" />
|
||||
<span>{t('profile.device_sync_pending', { count: pendingSyncCount })}</span>
|
||||
</>
|
||||
) : (
|
||||
@@ -710,7 +726,7 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="form-card">
|
||||
<section className="form-card profile-stats-section">
|
||||
<div className="form-header">
|
||||
<BarChart2 size={24} className="form-icon" />
|
||||
<div>
|
||||
@@ -720,7 +736,7 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
||||
</div>
|
||||
|
||||
{(statsTotals || profile) && (
|
||||
<div className="stats-kpi-grid">
|
||||
<div className="stats-kpi-grid profile-stats-kpi-grid">
|
||||
<KpiCard
|
||||
icon={<BookOpen size={20} />}
|
||||
label={t('profile.stats_logbooks')}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Compass, Palette, Save, Check, Cloud } from 'lucide-react'
|
||||
import ThemedSelect from './ThemedSelect.tsx'
|
||||
import PushNotificationSettings from './PushNotificationSettings.tsx'
|
||||
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
|
||||
import { notifyAppearanceChanged } from '../services/appearance.js'
|
||||
import { saveAppearancePrefsToServer } from '../services/appearancePrefs.js'
|
||||
import { useAppTour } from '../context/AppTourContext.tsx'
|
||||
import {
|
||||
getColorSchemePreference,
|
||||
getOwmApiKey,
|
||||
getThemePreference,
|
||||
setColorSchemePreference,
|
||||
setOwmApiKey,
|
||||
setThemePreference
|
||||
} from '../services/userPreferences.js'
|
||||
|
||||
interface UserProfilePreferencesProps {
|
||||
userId: string
|
||||
}
|
||||
|
||||
export default function UserProfilePreferences({ userId }: UserProfilePreferencesProps) {
|
||||
const { t } = useTranslation()
|
||||
const { restartTour } = useAppTour()
|
||||
const [apiKey, setApiKey] = useState(() => getOwmApiKey(userId))
|
||||
const [theme, setTheme] = useState(() => getThemePreference(userId))
|
||||
const [colorScheme, setColorScheme] = useState(() => getColorSchemePreference(userId))
|
||||
const [savingOwm, setSavingOwm] = useState(false)
|
||||
const [owmSaved, setOwmSaved] = useState(false)
|
||||
|
||||
const persistAppearance = (nextTheme: string, nextColorScheme: string) => {
|
||||
setThemePreference(userId, nextTheme)
|
||||
setColorSchemePreference(userId, nextColorScheme)
|
||||
notifyAppearanceChanged()
|
||||
void saveAppearancePrefsToServer(nextTheme, nextColorScheme).catch((err) => {
|
||||
console.warn('Failed to save appearance prefs to server:', err)
|
||||
})
|
||||
}
|
||||
|
||||
const handleThemeChange = (nextTheme: string) => {
|
||||
setTheme(nextTheme)
|
||||
persistAppearance(nextTheme, colorScheme)
|
||||
}
|
||||
|
||||
const handleColorSchemeChange = (nextColorScheme: string) => {
|
||||
setColorScheme(nextColorScheme)
|
||||
persistAppearance(theme, nextColorScheme)
|
||||
}
|
||||
|
||||
const handleSaveOwm = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSavingOwm(true)
|
||||
setOwmSaved(false)
|
||||
setOwmApiKey(userId, apiKey)
|
||||
setSavingOwm(false)
|
||||
setOwmSaved(true)
|
||||
window.setTimeout(() => setOwmSaved(false), 3000)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="member-editor-card glass">
|
||||
<div className="profile-section-header">
|
||||
<Palette size={20} />
|
||||
<h3>{t('profile.appearance_title')}</h3>
|
||||
</div>
|
||||
<p className="profile-section-desc">{t('profile.appearance_desc')}</p>
|
||||
|
||||
<div className="input-group">
|
||||
<label htmlFor="profile-app-theme" className="profile-field-label">
|
||||
{t('profile.theme_label')}
|
||||
</label>
|
||||
<ThemedSelect
|
||||
id="profile-app-theme"
|
||||
value={theme}
|
||||
onChange={handleThemeChange}
|
||||
options={[
|
||||
{ value: 'auto', label: t('profile.theme_auto') },
|
||||
{ value: 'ocean', label: t('profile.theme_ocean') },
|
||||
{ value: 'material', label: t('profile.theme_material') },
|
||||
{ value: 'cupertino', label: t('profile.theme_cupertino') }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group mt-4">
|
||||
<label htmlFor="profile-color-scheme" className="profile-field-label">
|
||||
{t('profile.color_scheme_label')}
|
||||
</label>
|
||||
<ThemedSelect
|
||||
id="profile-color-scheme"
|
||||
value={colorScheme}
|
||||
onChange={handleColorSchemeChange}
|
||||
options={[
|
||||
{ value: 'auto', label: t('profile.color_scheme_auto') },
|
||||
{ value: 'light', label: t('profile.color_scheme_light') },
|
||||
{ value: 'dark', label: t('profile.color_scheme_dark') }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="member-editor-card glass">
|
||||
<div className="profile-section-header">
|
||||
<Compass size={20} />
|
||||
<h3>{t('profile.tour_title')}</h3>
|
||||
</div>
|
||||
<p className="profile-section-desc">{t('profile.tour_desc')}</p>
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn secondary" onClick={() => restartTour()}>
|
||||
{t('profile.tour_restart')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="member-editor-card glass">
|
||||
<div className="profile-section-header">
|
||||
<Cloud size={20} />
|
||||
<h3>{t('profile.integrations_title')}</h3>
|
||||
</div>
|
||||
<p className="profile-section-desc">{t('profile.owm_help')}</p>
|
||||
<form onSubmit={handleSaveOwm}>
|
||||
<div className="input-group">
|
||||
<label htmlFor="profile-owm-api-key" className="profile-field-label">
|
||||
{t('profile.owm_key')}
|
||||
</label>
|
||||
<input
|
||||
id="profile-owm-api-key"
|
||||
name="owm-api-key"
|
||||
type="password"
|
||||
className="input-text"
|
||||
placeholder="e.g. 8b6a7f...d8"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
disabled={savingOwm}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-actions mt-4">
|
||||
{owmSaved && (
|
||||
<div className="success-toast">
|
||||
<Check size={16} />
|
||||
<span>{t('profile.prefs_saved')}</span>
|
||||
</div>
|
||||
)}
|
||||
<button type="submit" className="btn primary" disabled={savingOwm}>
|
||||
<Save size={18} />
|
||||
{savingOwm ? t('profile.prefs_saving') : t('profile.prefs_save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<PushNotificationSettings />
|
||||
<PwaInstallPrompt variant="inline" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { encryptJson, decryptJson } from '../services/crypto.js'
|
||||
import { syncLogbook } from '../services/sync.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { Ship, Save, Check, Plus, X, Camera, Trash2 } from 'lucide-react'
|
||||
import { parseOptionalTankLiters, tankCapacityInputFromStored } from '../utils/tankCapacity.js'
|
||||
|
||||
interface VesselFormProps {
|
||||
logbookId: string
|
||||
@@ -47,6 +48,9 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
|
||||
const [mmsi, setMmsi] = useState('')
|
||||
const [sails, setSails] = useState<string[]>([])
|
||||
const [newSailName, setNewSailName] = useState('')
|
||||
const [freshwaterCapacityL, setFreshwaterCapacityL] = useState('')
|
||||
const [fuelCapacityL, setFuelCapacityL] = useState('')
|
||||
const [greywaterCapacityL, setGreywaterCapacityL] = useState('')
|
||||
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null)
|
||||
const [photo, setPhoto] = useState<string | null>(null)
|
||||
@@ -78,6 +82,9 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
|
||||
setMmsi(preloadedData.mmsi || '')
|
||||
setSails(preloadedData.sails || [])
|
||||
setPhoto(preloadedData.photo || null)
|
||||
setFreshwaterCapacityL(tankCapacityInputFromStored(preloadedData.freshwaterCapacityL))
|
||||
setFuelCapacityL(tankCapacityInputFromStored(preloadedData.fuelCapacityL))
|
||||
setGreywaterCapacityL(tankCapacityInputFromStored(preloadedData.greywaterCapacityL))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -103,6 +110,9 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
|
||||
setMmsi(decrypted.mmsi || '')
|
||||
setSails(decrypted.sails || [])
|
||||
setPhoto(decrypted.photo || null)
|
||||
setFreshwaterCapacityL(tankCapacityInputFromStored(decrypted.freshwaterCapacityL))
|
||||
setFuelCapacityL(tankCapacityInputFromStored(decrypted.fuelCapacityL))
|
||||
setGreywaterCapacityL(tankCapacityInputFromStored(decrypted.greywaterCapacityL))
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
@@ -201,12 +211,19 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
|
||||
let parsedLengthM: number | undefined
|
||||
let parsedDraftM: number | undefined
|
||||
let parsedAirDraftM: number | undefined
|
||||
let parsedFreshwaterCapacityL: number | undefined
|
||||
let parsedFuelCapacityL: number | undefined
|
||||
let parsedGreywaterCapacityL: number | undefined
|
||||
try {
|
||||
parsedLengthM = parseOptionalMetricMeters(lengthM)
|
||||
parsedDraftM = parseOptionalMetricMeters(draftM)
|
||||
parsedAirDraftM = parseOptionalMetricMeters(airDraftM)
|
||||
} catch {
|
||||
setError(t('vessel.invalid_metric'))
|
||||
parsedFreshwaterCapacityL = parseOptionalTankLiters(freshwaterCapacityL)
|
||||
parsedFuelCapacityL = parseOptionalTankLiters(fuelCapacityL)
|
||||
parsedGreywaterCapacityL = parseOptionalTankLiters(greywaterCapacityL)
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : ''
|
||||
setError(msg === 'invalid_tank_liters' ? t('vessel.invalid_tank_liters') : t('vessel.invalid_metric'))
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
@@ -217,6 +234,9 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
|
||||
lengthM: parsedLengthM,
|
||||
draftM: parsedDraftM,
|
||||
airDraftM: parsedAirDraftM,
|
||||
freshwaterCapacityL: parsedFreshwaterCapacityL,
|
||||
fuelCapacityL: parsedFuelCapacityL,
|
||||
greywaterCapacityL: parsedGreywaterCapacityL,
|
||||
homePort: homePort.trim(),
|
||||
charterCompany: charterCompany.trim(),
|
||||
owner: owner.trim(),
|
||||
@@ -480,6 +500,49 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="vessel-tanks-section">
|
||||
<h3>{t('vessel.tanks_section')}</h3>
|
||||
<p className="vessel-tanks-help">{t('vessel.tanks_help')}</p>
|
||||
<div className="vessel-tanks-grid">
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.freshwater_capacity_l')}</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="input-text"
|
||||
value={freshwaterCapacityL}
|
||||
onChange={(e) => setFreshwaterCapacityL(e.target.value)}
|
||||
disabled={saving || readOnly}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.fuel_capacity_l')}</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="input-text"
|
||||
value={fuelCapacityL}
|
||||
onChange={(e) => setFuelCapacityL(e.target.value)}
|
||||
disabled={saving || readOnly}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.greywater_capacity_l')}</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="input-text"
|
||||
value={greywaterCapacityL}
|
||||
onChange={(e) => setGreywaterCapacityL(e.target.value)}
|
||||
disabled={saving || readOnly}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sails-section">
|
||||
<h3>{t('vessel.sails_list')}</h3>
|
||||
<p className="help-text">{t('vessel.sails_help')}</p>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
DEMO_EXCLUDED_STEPS,
|
||||
DEMO_STEP_ORDER,
|
||||
FULL_STEP_ORDER,
|
||||
getTourScrollRetryDelays,
|
||||
getTourTargetRetryDelay,
|
||||
tourStepOpensEntry
|
||||
} from './AppTourContext.tsx'
|
||||
|
||||
describe('AppTourContext step order', () => {
|
||||
it('includes profile steps before finish in full tour', () => {
|
||||
const profileIndex = FULL_STEP_ORDER.indexOf('nav_profile')
|
||||
const prefsIndex = FULL_STEP_ORDER.indexOf('profile_preferences')
|
||||
const finishIndex = FULL_STEP_ORDER.indexOf('finish')
|
||||
|
||||
expect(profileIndex).toBeGreaterThan(FULL_STEP_ORDER.indexOf('nav_feedback'))
|
||||
expect(prefsIndex).toBe(profileIndex + 1)
|
||||
expect(finishIndex).toBe(prefsIndex + 1)
|
||||
expect(FULL_STEP_ORDER).toHaveLength(12)
|
||||
})
|
||||
|
||||
it('excludes profile, stats and feedback from demo tour', () => {
|
||||
for (const step of DEMO_EXCLUDED_STEPS) {
|
||||
expect(DEMO_STEP_ORDER).not.toContain(step)
|
||||
}
|
||||
expect(DEMO_STEP_ORDER).toContain('finish')
|
||||
expect(DEMO_STEP_ORDER).toHaveLength(FULL_STEP_ORDER.length - DEMO_EXCLUDED_STEPS.length)
|
||||
})
|
||||
|
||||
it('only opens entry editor on entry_track step', () => {
|
||||
expect(tourStepOpensEntry('entry_open')).toBe(false)
|
||||
expect(tourStepOpensEntry('entry_list')).toBe(false)
|
||||
expect(tourStepOpensEntry('entry_track')).toBe(true)
|
||||
})
|
||||
|
||||
it('retries scroll for entry_track while editor mounts', () => {
|
||||
expect(getTourTargetRetryDelay('entry_track')).toBeGreaterThanOrEqual(400)
|
||||
expect(getTourScrollRetryDelays('entry_track').length).toBeGreaterThan(1)
|
||||
})
|
||||
})
|
||||
@@ -29,12 +29,17 @@ export type TourStepId =
|
||||
| 'nav_crew'
|
||||
| 'nav_stats'
|
||||
| 'nav_feedback'
|
||||
| 'nav_profile'
|
||||
| 'profile_preferences'
|
||||
| 'finish'
|
||||
|
||||
interface TourNavigation {
|
||||
setActiveTab: (tab: AppTab) => void
|
||||
setSelectedEntryId: (entryId: string | null) => void
|
||||
setFeedbackOpen: (open: boolean) => void
|
||||
setLogbookActive: (active: boolean) => void
|
||||
setProfileOpen: (open: boolean) => void
|
||||
ensureLogbookForTour?: () => Promise<void>
|
||||
}
|
||||
|
||||
interface DemoTourContext {
|
||||
@@ -47,6 +52,7 @@ interface AppTourContextValue {
|
||||
currentStepId: TourStepId | null
|
||||
currentStepIndex: number
|
||||
totalSteps: number
|
||||
layoutTick: number
|
||||
startTour: (options?: { force?: boolean; demoMode?: boolean }) => void
|
||||
stopTour: () => void
|
||||
restartTour: () => void
|
||||
@@ -58,7 +64,7 @@ interface AppTourContextValue {
|
||||
requestStartAfterLogin: () => void
|
||||
}
|
||||
|
||||
const FULL_STEP_ORDER: TourStepId[] = [
|
||||
export const FULL_STEP_ORDER: TourStepId[] = [
|
||||
'welcome',
|
||||
'nav_logs',
|
||||
'entry_list',
|
||||
@@ -68,12 +74,33 @@ const FULL_STEP_ORDER: TourStepId[] = [
|
||||
'nav_crew',
|
||||
'nav_stats',
|
||||
'nav_feedback',
|
||||
'nav_profile',
|
||||
'profile_preferences',
|
||||
'finish'
|
||||
]
|
||||
|
||||
/** Public demo has no stats/feedback UI — skip those steps. */
|
||||
const DEMO_EXCLUDED_STEPS: TourStepId[] = ['nav_stats', 'nav_feedback']
|
||||
const DEMO_STEP_ORDER: TourStepId[] = FULL_STEP_ORDER.filter((id) => !DEMO_EXCLUDED_STEPS.includes(id))
|
||||
/** Public demo has no stats/feedback/profile UI — skip those steps. */
|
||||
export const DEMO_EXCLUDED_STEPS: TourStepId[] = [
|
||||
'nav_stats',
|
||||
'nav_feedback',
|
||||
'nav_profile',
|
||||
'profile_preferences'
|
||||
]
|
||||
|
||||
export const DEMO_STEP_ORDER: TourStepId[] = FULL_STEP_ORDER.filter(
|
||||
(id) => !DEMO_EXCLUDED_STEPS.includes(id)
|
||||
)
|
||||
|
||||
const LOGBOOK_TOUR_STEPS = new Set<TourStepId>([
|
||||
'nav_logs',
|
||||
'entry_list',
|
||||
'entry_open',
|
||||
'entry_track',
|
||||
'nav_vessel',
|
||||
'nav_crew',
|
||||
'nav_stats',
|
||||
'nav_feedback'
|
||||
])
|
||||
|
||||
function getStepOrder(demoMode: boolean): TourStepId[] {
|
||||
return demoMode ? DEMO_STEP_ORDER : FULL_STEP_ORDER
|
||||
@@ -87,7 +114,28 @@ const TARGET_BY_STEP: Partial<Record<TourStepId, string>> = {
|
||||
nav_vessel: '[data-tour="nav-vessel"]',
|
||||
nav_crew: '[data-tour="nav-crew"]',
|
||||
nav_stats: '[data-tour="stats-dashboard"]',
|
||||
nav_feedback: '[data-tour="feedback-form"]'
|
||||
nav_feedback: '[data-tour="feedback-form"]',
|
||||
nav_profile: '[data-tour="nav-profile"]',
|
||||
profile_preferences: '[data-tour="profile-preferences"]'
|
||||
}
|
||||
|
||||
/** Whether a tour step opens the first log entry editor (not the list card). */
|
||||
export function tourStepOpensEntry(stepId: TourStepId): boolean {
|
||||
return stepId === 'entry_track'
|
||||
}
|
||||
|
||||
export function getTourTargetDelay(stepId: TourStepId): number {
|
||||
if (stepId === 'entry_track') return 400
|
||||
if (stepId === 'nav_feedback') return 180
|
||||
if (stepId === 'nav_profile' || stepId === 'profile_preferences') return 250
|
||||
return 0
|
||||
}
|
||||
|
||||
/** Extra scroll attempts while async UI (e.g. entry editor) mounts. */
|
||||
export function getTourScrollRetryDelays(stepId: TourStepId): number[] {
|
||||
if (stepId === 'entry_track') return [400, 700, 1100, 1600]
|
||||
const initial = getTourTargetDelay(stepId)
|
||||
return initial > 0 ? [initial] : [0]
|
||||
}
|
||||
|
||||
const AppTourContext = createContext<AppTourContextValue | null>(null)
|
||||
@@ -97,6 +145,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
||||
const [stepIndex, setStepIndex] = useState(0)
|
||||
const [pendingAfterLogin, setPendingAfterLogin] = useState(false)
|
||||
const [isDemoTour, setIsDemoTour] = useState(false)
|
||||
const [layoutTick, setLayoutTick] = useState(0)
|
||||
const navigationRef = useRef<TourNavigation | null>(null)
|
||||
const demoContextRef = useRef<DemoTourContext | null>(null)
|
||||
const tourModeRef = useRef<{ demoMode: boolean }>({ demoMode: false })
|
||||
@@ -112,13 +161,24 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
||||
const nav = navigationRef.current
|
||||
if (!nav) return
|
||||
|
||||
if (LOGBOOK_TOUR_STEPS.has(stepId)) {
|
||||
nav.setProfileOpen(false)
|
||||
nav.setLogbookActive(true)
|
||||
}
|
||||
|
||||
if (stepId === 'nav_logs' || stepId === 'entry_list' || stepId === 'entry_open' || stepId === 'entry_track') {
|
||||
nav.setActiveTab('logs')
|
||||
}
|
||||
if (stepId === 'entry_open' || stepId === 'entry_track') {
|
||||
|
||||
if (stepId === 'entry_list' || stepId === 'entry_open') {
|
||||
nav.setSelectedEntryId(null)
|
||||
} else if (tourStepOpensEntry(stepId)) {
|
||||
const firstEntryId = resolveFirstEntryId()
|
||||
if (firstEntryId) nav.setSelectedEntryId(firstEntryId)
|
||||
} else if (LOGBOOK_TOUR_STEPS.has(stepId)) {
|
||||
nav.setSelectedEntryId(null)
|
||||
}
|
||||
|
||||
if (stepId === 'nav_vessel') {
|
||||
nav.setSelectedEntryId(null)
|
||||
nav.setActiveTab('vessel')
|
||||
@@ -137,19 +197,34 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
||||
} else {
|
||||
nav.setFeedbackOpen(false)
|
||||
}
|
||||
|
||||
if (stepId === 'nav_profile') {
|
||||
nav.setProfileOpen(false)
|
||||
nav.setLogbookActive(false)
|
||||
}
|
||||
if (stepId === 'profile_preferences') {
|
||||
nav.setLogbookActive(false)
|
||||
nav.setProfileOpen(true)
|
||||
}
|
||||
}, [resolveFirstEntryId])
|
||||
|
||||
const scrollToCurrentTarget = useCallback((stepId: TourStepId | null) => {
|
||||
if (!stepId) return
|
||||
const selector = TARGET_BY_STEP[stepId]
|
||||
if (!selector) return
|
||||
const delayMs = stepId === 'nav_feedback' ? 180 : 0
|
||||
window.setTimeout(() => {
|
||||
window.requestAnimationFrame(() => {
|
||||
const el = document.querySelector(selector)
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' })
|
||||
})
|
||||
}, delayMs)
|
||||
|
||||
for (const delayMs of getTourScrollRetryDelays(stepId)) {
|
||||
window.setTimeout(() => {
|
||||
window.requestAnimationFrame(() => {
|
||||
const el = document.querySelector(selector)
|
||||
el?.scrollIntoView({
|
||||
behavior: stepId === 'entry_track' ? 'instant' : 'smooth',
|
||||
block: 'center',
|
||||
inline: 'nearest'
|
||||
})
|
||||
})
|
||||
}, delayMs)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startTour = useCallback((options?: { force?: boolean; demoMode?: boolean }) => {
|
||||
@@ -173,6 +248,8 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
||||
trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_COMPLETED, tourProps)
|
||||
const nav = navigationRef.current
|
||||
if (nav && !tourModeRef.current.demoMode) {
|
||||
nav.setProfileOpen(false)
|
||||
nav.setLogbookActive(true)
|
||||
nav.setSelectedEntryId(null)
|
||||
nav.setActiveTab('stats')
|
||||
}
|
||||
@@ -183,6 +260,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
tourModeRef.current = { demoMode: false }
|
||||
navigationRef.current?.setFeedbackOpen(false)
|
||||
navigationRef.current?.setProfileOpen(false)
|
||||
setIsDemoTour(false)
|
||||
setIsActive(false)
|
||||
setStepIndex(0)
|
||||
@@ -213,8 +291,25 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
||||
if (!isActive) return
|
||||
const stepId = getStepOrder(isDemoTour)[stepIndex]
|
||||
if (!stepId) return
|
||||
applyStepSideEffects(stepId)
|
||||
scrollToCurrentTarget(stepId)
|
||||
|
||||
let cancelled = false
|
||||
const run = async () => {
|
||||
if (LOGBOOK_TOUR_STEPS.has(stepId) && !isDemoTour) {
|
||||
await navigationRef.current?.ensureLogbookForTour?.()
|
||||
}
|
||||
if (cancelled) return
|
||||
applyStepSideEffects(stepId)
|
||||
scrollToCurrentTarget(stepId)
|
||||
setLayoutTick((tick) => tick + 1)
|
||||
window.setTimeout(() => {
|
||||
if (!cancelled) setLayoutTick((tick) => tick + 1)
|
||||
}, 150)
|
||||
}
|
||||
void run()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [isActive, isDemoTour, stepIndex, applyStepSideEffects, scrollToCurrentTarget])
|
||||
|
||||
const restartTour = useCallback(() => {
|
||||
@@ -257,6 +352,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
||||
currentStepId,
|
||||
currentStepIndex: stepIndex,
|
||||
totalSteps: stepOrder.length,
|
||||
layoutTick,
|
||||
startTour,
|
||||
stopTour,
|
||||
restartTour,
|
||||
@@ -281,6 +377,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
||||
startTour,
|
||||
stepIndex,
|
||||
stepOrder.length,
|
||||
layoutTick,
|
||||
stopTour
|
||||
]
|
||||
)
|
||||
@@ -321,3 +418,10 @@ export function getTourTargetSelector(stepId: TourStepId | null): string | null
|
||||
export function isCenteredTourStep(stepId: TourStepId | null): boolean {
|
||||
return stepId === 'welcome' || stepId === 'finish'
|
||||
}
|
||||
|
||||
export function getTourTargetRetryDelay(stepId: TourStepId | null): number {
|
||||
if (stepId === 'entry_track') return 400
|
||||
if (stepId === 'profile_preferences') return 300
|
||||
if (stepId === 'nav_profile') return 200
|
||||
return 120
|
||||
}
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useRegisterSW } from 'virtual:pwa-register/react'
|
||||
import {
|
||||
forcePwaRecovery,
|
||||
markReloadAttempt,
|
||||
recentlyAttemptedReload,
|
||||
triggerServiceWorkerUpdate
|
||||
} from '../services/pwaStartup.js'
|
||||
import { isDeployedVersionNewer } from '../services/pwaVersion.js'
|
||||
|
||||
const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000
|
||||
const UPDATE_CHECK_INTERVAL_MS = 15 * 60 * 1000
|
||||
const UPDATE_SUPPRESS_KEY = 'pwa_update_suppress_until'
|
||||
const UPDATE_SUPPRESS_MS = 30_000
|
||||
const UPDATE_DISMISS_SUPPRESS_MS = 60 * 60 * 1000
|
||||
const UPDATE_RELOAD_FALLBACK_MS = 2000
|
||||
const UPDATE_DISMISS_SUPPRESS_MS = 15 * 60 * 1000
|
||||
const UPDATE_RELOAD_FALLBACK_MS = 2_000
|
||||
const UPDATE_HARD_RECOVERY_MS = 5_000
|
||||
|
||||
function isUpdateSuppressed(): boolean {
|
||||
const suppressUntil = Number(sessionStorage.getItem(UPDATE_SUPPRESS_KEY) || '0')
|
||||
@@ -20,10 +28,16 @@ function clearUpdateSuppression(): void {
|
||||
sessionStorage.removeItem(UPDATE_SUPPRESS_KEY)
|
||||
}
|
||||
|
||||
function scheduleUpdateChecks(registration: ServiceWorkerRegistration): () => void {
|
||||
function scheduleUpdateChecks(
|
||||
registration: ServiceWorkerRegistration,
|
||||
onOutdated: () => void
|
||||
): () => void {
|
||||
const checkForUpdate = () => {
|
||||
if (isUpdateSuppressed()) return
|
||||
registration.update().catch(() => {})
|
||||
void isDeployedVersionNewer().then((outdated) => {
|
||||
if (outdated) onOutdated()
|
||||
})
|
||||
}
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
@@ -32,52 +46,101 @@ function scheduleUpdateChecks(registration: ServiceWorkerRegistration): () => vo
|
||||
}
|
||||
}
|
||||
|
||||
const onOnline = () => {
|
||||
checkForUpdate()
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
const intervalId = window.setInterval(checkForUpdate, UPDATE_CHECK_INTERVAL_MS)
|
||||
window.addEventListener('online', onOnline)
|
||||
const updateIntervalId = window.setInterval(checkForUpdate, UPDATE_CHECK_INTERVAL_MS)
|
||||
|
||||
checkForUpdate()
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
window.clearInterval(intervalId)
|
||||
window.removeEventListener('online', onOnline)
|
||||
window.clearInterval(updateIntervalId)
|
||||
}
|
||||
}
|
||||
|
||||
function reloadForServiceWorkerTakeover(): void {
|
||||
if (recentlyAttemptedReload()) return
|
||||
markReloadAttempt()
|
||||
clearUpdateSuppression()
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
export function usePwaUpdate() {
|
||||
const cleanupRef = useRef<(() => void) | null>(null)
|
||||
const reloadFallbackTimerRef = useRef<number | null>(null)
|
||||
const forceRecoveryTimerRef = useRef<number | null>(null)
|
||||
const setNeedRefreshRef = useRef<((value: boolean) => void) | null>(null)
|
||||
const pendingNeedRefreshRef = useRef<boolean | null>(null)
|
||||
|
||||
const applyNeedRefresh = (value: boolean) => {
|
||||
if (setNeedRefreshRef.current) {
|
||||
setNeedRefreshRef.current(value)
|
||||
return
|
||||
}
|
||||
pendingNeedRefreshRef.current = value
|
||||
}
|
||||
|
||||
const {
|
||||
needRefresh: [needRefresh, setNeedRefresh],
|
||||
updateServiceWorker
|
||||
} = useRegisterSW({
|
||||
immediate: true,
|
||||
immediate: !import.meta.env.DEV,
|
||||
onNeedReload() {
|
||||
clearUpdateSuppression()
|
||||
setNeedRefresh(false)
|
||||
window.location.reload()
|
||||
if (isUpdateSuppressed()) return
|
||||
applyNeedRefresh(true)
|
||||
},
|
||||
onNeedRefresh() {
|
||||
if (isUpdateSuppressed()) return
|
||||
setNeedRefresh(true)
|
||||
applyNeedRefresh(true)
|
||||
},
|
||||
onRegisteredSW(_swUrl: string, registration: ServiceWorkerRegistration | undefined) {
|
||||
if (!registration) return
|
||||
|
||||
if (isUpdateSuppressed() || !registration.waiting) {
|
||||
setNeedRefresh(false)
|
||||
applyNeedRefresh(false)
|
||||
}
|
||||
|
||||
cleanupRef.current?.()
|
||||
cleanupRef.current = scheduleUpdateChecks(registration)
|
||||
cleanupRef.current = scheduleUpdateChecks(registration, () => {
|
||||
if (isUpdateSuppressed()) return
|
||||
applyNeedRefresh(true)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
setNeedRefreshRef.current = setNeedRefresh
|
||||
|
||||
useEffect(() => {
|
||||
if (isUpdateSuppressed()) {
|
||||
setNeedRefresh(false)
|
||||
} else if (pendingNeedRefreshRef.current !== null) {
|
||||
const pending = pendingNeedRefreshRef.current
|
||||
pendingNeedRefreshRef.current = null
|
||||
setNeedRefresh(pending)
|
||||
}
|
||||
|
||||
void isDeployedVersionNewer().then((outdated) => {
|
||||
if (outdated) {
|
||||
setNeedRefresh(true)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cleanupRef.current?.()
|
||||
cleanupRef.current = null
|
||||
if (reloadFallbackTimerRef.current !== null) {
|
||||
window.clearTimeout(reloadFallbackTimerRef.current)
|
||||
reloadFallbackTimerRef.current = null
|
||||
}
|
||||
if (forceRecoveryTimerRef.current !== null) {
|
||||
window.clearTimeout(forceRecoveryTimerRef.current)
|
||||
forceRecoveryTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [setNeedRefresh])
|
||||
|
||||
@@ -86,11 +149,24 @@ export function usePwaUpdate() {
|
||||
suppressUpdatePrompt()
|
||||
|
||||
await updateServiceWorker(true)
|
||||
await triggerServiceWorkerUpdate()
|
||||
|
||||
// vite-plugin-pwa reloads via the "controlling" event; fallback if that does not fire.
|
||||
window.setTimeout(() => {
|
||||
window.location.reload()
|
||||
if (reloadFallbackTimerRef.current !== null) {
|
||||
window.clearTimeout(reloadFallbackTimerRef.current)
|
||||
}
|
||||
if (forceRecoveryTimerRef.current !== null) {
|
||||
window.clearTimeout(forceRecoveryTimerRef.current)
|
||||
}
|
||||
|
||||
reloadFallbackTimerRef.current = window.setTimeout(() => {
|
||||
reloadFallbackTimerRef.current = null
|
||||
reloadForServiceWorkerTakeover()
|
||||
}, UPDATE_RELOAD_FALLBACK_MS)
|
||||
|
||||
forceRecoveryTimerRef.current = window.setTimeout(() => {
|
||||
forceRecoveryTimerRef.current = null
|
||||
void forcePwaRecovery()
|
||||
}, UPDATE_HARD_RECOVERY_MS)
|
||||
}
|
||||
|
||||
const dismissUpdate = () => {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import { db } from '../services/db.js'
|
||||
import { subscribeToSyncState } from '../services/sync.js'
|
||||
|
||||
export type SyncConnStatusVariant = 'offline' | 'syncing' | 'pending' | 'online'
|
||||
|
||||
/** Maps sync/online state to conn-status CSS modifier classes. */
|
||||
export function syncConnStatusClassName(
|
||||
online: boolean,
|
||||
showSpinner: boolean,
|
||||
pendingCount: number
|
||||
): string {
|
||||
if (!online) return 'conn-status offline'
|
||||
if (showSpinner) return 'conn-status syncing'
|
||||
if (pendingCount > 0) return 'conn-status warning'
|
||||
return 'conn-status online'
|
||||
}
|
||||
|
||||
/** Sync queue depth and whether a sync pass is running (for header indicators). */
|
||||
export function useSyncIndicator(logbookId?: string | null) {
|
||||
const [isSyncing, setIsSyncing] = useState(false)
|
||||
|
||||
const pendingCount =
|
||||
useLiveQuery(
|
||||
() =>
|
||||
logbookId
|
||||
? db.syncQueue.where({ logbookId }).count()
|
||||
: db.syncQueue.count(),
|
||||
[logbookId]
|
||||
) ?? 0
|
||||
|
||||
useEffect(() => {
|
||||
return subscribeToSyncState(setIsSyncing)
|
||||
}, [])
|
||||
|
||||
const showSpinner = isSyncing
|
||||
const showPendingWarning = pendingCount > 0 && !isSyncing
|
||||
|
||||
return {
|
||||
isSyncing,
|
||||
pendingCount,
|
||||
showSpinner,
|
||||
showPendingWarning,
|
||||
connStatusClassName: (online: boolean) =>
|
||||
syncConnStatusClassName(online, showSpinner, pendingCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import deJson from './locales/de.json'
|
||||
import enJson from './locales/en.json'
|
||||
|
||||
const resources = {
|
||||
de: { translation: deJson.translation },
|
||||
en: { translation: enJson.translation }
|
||||
}
|
||||
|
||||
describe('course dial i18n keys', () => {
|
||||
it.each([
|
||||
'logs.event_course_section',
|
||||
'logs.course_tab_mgk',
|
||||
'logs.course_tab_rwk',
|
||||
'logs.course_dial_hint',
|
||||
'logs.course_step_fine',
|
||||
'logs.wind_mode_cardinal'
|
||||
])('resolves %s in de and en bundles', async (key) => {
|
||||
const { default: i18n } = await import('i18next')
|
||||
await i18n.init({ lng: 'de', resources, defaultNS: 'translation' })
|
||||
expect(i18n.t(key)).not.toBe(key)
|
||||
await i18n.changeLanguage('en')
|
||||
expect(i18n.t(key)).not.toBe(key)
|
||||
})
|
||||
})
|
||||
@@ -1,19 +1,33 @@
|
||||
import i18n from 'i18next'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
import LanguageDetector from 'i18next-browser-languagedetector'
|
||||
import enTranslation from './locales/en.json'
|
||||
import deTranslation from './locales/de.json'
|
||||
import enJson from './locales/en.json'
|
||||
import deJson from './locales/de.json'
|
||||
import daJson from './locales/da.json'
|
||||
import svJson from './locales/sv.json'
|
||||
import nbJson from './locales/nb.json'
|
||||
import { initSeo } from '../utils/seo.js'
|
||||
import { SUPPORTED_LANGUAGES } from '../utils/i18nLanguages.js'
|
||||
|
||||
/** JSON files wrap strings in `translation` — register that namespace explicitly. */
|
||||
const resources = {
|
||||
en: { translation: enJson.translation },
|
||||
de: { translation: deJson.translation },
|
||||
da: { translation: daJson.translation },
|
||||
sv: { translation: svJson.translation },
|
||||
nb: { translation: nbJson.translation }
|
||||
}
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources: {
|
||||
en: enTranslation,
|
||||
de: deTranslation
|
||||
},
|
||||
resources,
|
||||
defaultNS: 'translation',
|
||||
fallbackLng: 'en',
|
||||
supportedLngs: [...SUPPORTED_LANGUAGES],
|
||||
nonExplicitSupportedLngs: true,
|
||||
load: 'languageOnly',
|
||||
interpolation: {
|
||||
escapeValue: false // React already escapes values (prevents XSS)
|
||||
},
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import deJson from '../i18n/locales/de.json'
|
||||
import enJson from '../i18n/locales/en.json'
|
||||
import daJson from '../i18n/locales/da.json'
|
||||
import svJson from '../i18n/locales/sv.json'
|
||||
import nbJson from '../i18n/locales/nb.json'
|
||||
|
||||
function collectKeys(obj: Record<string, unknown>, prefix = ''): string[] {
|
||||
const keys: string[] = []
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const path = prefix ? `${prefix}.${key}` : key
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
keys.push(...collectKeys(value as Record<string, unknown>, path))
|
||||
} else {
|
||||
keys.push(path)
|
||||
}
|
||||
}
|
||||
return keys.sort()
|
||||
}
|
||||
|
||||
const bundles = {
|
||||
de: deJson.translation,
|
||||
en: enJson.translation,
|
||||
da: daJson.translation,
|
||||
sv: svJson.translation,
|
||||
nb: nbJson.translation
|
||||
} as const
|
||||
|
||||
describe('i18n locale key parity', () => {
|
||||
const masterKeys = collectKeys(bundles.de)
|
||||
|
||||
it.each(Object.keys(bundles).filter((lang) => lang !== 'de'))(
|
||||
'%s has the same keys as de',
|
||||
(lang) => {
|
||||
const keys = collectKeys(bundles[lang as keyof typeof bundles])
|
||||
expect(keys).toEqual(masterKeys)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,887 @@
|
||||
{
|
||||
"translation": {
|
||||
"app": {
|
||||
"name": "Kapteins Daagbok",
|
||||
"tagline": "Privat yacht-logbog",
|
||||
"beta": "Beta",
|
||||
"beta_hint": "Betaversion - funktioner kan stadig ændres"
|
||||
},
|
||||
"languages": {
|
||||
"de": "Deutsch",
|
||||
"en": "English",
|
||||
"da": "Dansk",
|
||||
"sv": "Svenska",
|
||||
"nb": "Norsk"
|
||||
},
|
||||
"common": {
|
||||
"unsaved_changes_title": "Ikke gemte ændringer",
|
||||
"unsaved_changes_message": "Du har ændringer, der ikke er gemt. Vil du virkelig forlade siden? Dine ændringer vil gå tabt.",
|
||||
"unsaved_changes_leave": "Forladelse",
|
||||
"unsaved_changes_stay": "Bliv her"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"vessel": "Skibsdata",
|
||||
"crew": "Besætningsliste",
|
||||
"deviation": "Tabel over distraktioner",
|
||||
"logs": "Indlæg i logbogen",
|
||||
"stats": "Statistik",
|
||||
"settings": "Indstillinger"
|
||||
},
|
||||
"auth": {
|
||||
"welcome": "Velkommen til Kapteins Daagbok.",
|
||||
"tagline": "Din sikre, E2E-krypterede maritime logbog.",
|
||||
"register": "Registrer dig med Passkey.",
|
||||
"login": "Log ind med Passkey.",
|
||||
"login_as": "Log ind som {{name}}",
|
||||
"quick_login": "Hurtigt login",
|
||||
"forget_account": "Glemt konto på denne enhed",
|
||||
"not_user": "Ikke {{name}}?",
|
||||
"recovery_title": "Din genoprettelsesnøgle",
|
||||
"recovery_warning": "VIGTIGT: Skriv disse 12 ord ned. Hvis du mister din Passkey og disse ord, kan dine data ikke gendannes.",
|
||||
"confirm_recovery": "Jeg har skrevet ordene ned",
|
||||
"status_logged_in": "Logget ind",
|
||||
"status_logged_out": "Aflyst",
|
||||
"copied": "Kopieret!",
|
||||
"copy_phrase": "Kopieringstast",
|
||||
"enter_recovery": "Indtast genoprettelsesnøgle",
|
||||
"recovery_fallback_warning": "Din Passkey er blevet godkendt, men din enhed understøtter ikke hardwarebaseret nøgleafledning. Indtast din genoprettelsesnøgle på 12 ord for at dekryptere din logbog.",
|
||||
"recovery_placeholder": "Indtast din genoprettelsesnøgle, som består af 12 ord adskilt af mellemrum...",
|
||||
"back": "Tilbage",
|
||||
"decrypting": "Dekryptering...",
|
||||
"decrypt_logbook": "Afkodning af logbog",
|
||||
"error_incorrect_recovery": "Forkert genoprettelsesnøgle. Dekryptering mislykkedes.",
|
||||
"error_decryption_failed": "Dekryptering mislykkedes. Tjek venligst din genoprettelsesnøgle.",
|
||||
"or_register": "eller registrer dig",
|
||||
"explore_demo": "Udforsk demoen uden en konto",
|
||||
"username_placeholder": "Brugernavn / skippernavn",
|
||||
"processing": "Behandling...",
|
||||
"help": "Hjælp",
|
||||
"setup_pin_title": "Opsæt lokal PIN-kode (valgfrit)",
|
||||
"setup_pin_warning": "Da din enhed ikke understøtter direkte Passkey-nøgleafledning, ville du ellers være nødt til at indtaste din 12-ordsnøgle, hver gang du logger ind på denne enhed. Opsæt en lokal PIN-kode for at undgå dette.",
|
||||
"pin_placeholder": "E.G. 123456",
|
||||
"pin_label": "Lokal PIN-kode (4-8 cifre)",
|
||||
"save_pin": "Gem PIN-kode og fortsæt",
|
||||
"skip_pin": "Spring over og brug gendannelse",
|
||||
"enter_pin_title": "Afkodning med PIN-kode",
|
||||
"enter_pin_warning": "Indtast din lokale PIN-kode for at låse op for dekrypteringsnøglen på denne enhed.",
|
||||
"enter_pin_placeholder": "Indtast din pinkode...",
|
||||
"decrypt_with_pin": "Afkodning",
|
||||
"use_recovery_instead": "Brug genoprettelsesnøgler i stedet",
|
||||
"error_incorrect_pin": "Forkert PIN-kode. Dekryptering mislykkedes.",
|
||||
"error_invalid_host": "Passkeys virker ikke via 127.0.0.1. Åbn appen via localhost.",
|
||||
"use_localhost_link": "Skift til localhost",
|
||||
"error_passkey_cancelled": "Passkey-login blev annulleret eller udløb. Prøv igen.",
|
||||
"error_invalid_rp_id": "Passkey-domæne matcher ikke (RP ID). Brug http://localhost:5173 med RP_ID=localhost i .env til lokal udvikling.",
|
||||
"error_session_incomplete": "Login ufuldstændig. Log ind med passkey igen."
|
||||
},
|
||||
"pwa": {
|
||||
"title": "Installer app",
|
||||
"generic_benefit": "Installer Kapteins Daagbok på din enhed for at få hurtigere adgang, offline-brug og permanent datalagring.",
|
||||
"ios_instructions": "På iPad/iPhone: Føj appen til startskærmen, så dine logbogsdata forbliver beskyttet, og appen starter som en indbygget app.",
|
||||
"ios_step_share": "Tryk på aktiesymbolet i Safari-linjen",
|
||||
"ios_step_add": "Vælg \"Gå til startskærm\"",
|
||||
"install_now": "Installer nu",
|
||||
"installing": "Installation...",
|
||||
"later": "Senere",
|
||||
"never": "Vis ikke mere",
|
||||
"platform_ios": "Installation via Safari.",
|
||||
"platform_android": "Installation via browseren",
|
||||
"platform_desktop": "Installation som desktop-app",
|
||||
"settings_section": "Installation af app",
|
||||
"update_title": "Opdatering tilgængelig",
|
||||
"update_desc": "En ny version af Kapteins Daagbok er klar. Opdater venligst for at få de seneste ændringer.",
|
||||
"update_now": "Opdater nu",
|
||||
"update_reloading": "Indlæser..."
|
||||
},
|
||||
"sync": {
|
||||
"status_synced": "Synkroniseret",
|
||||
"status_syncing": "Synkroniser...",
|
||||
"status_offline": "Offline-cache",
|
||||
"status_unsynced": "Usynkroniserede ændringer"
|
||||
},
|
||||
"vessel": {
|
||||
"title": "Skibets stamdata",
|
||||
"name": "Yacht-navn",
|
||||
"type": "Yacht-type",
|
||||
"type_unset": "- ikke specificeret -",
|
||||
"type_sailing": "Sejlbåd",
|
||||
"type_motor": "Motorbåd",
|
||||
"length_m": "Længde (m)",
|
||||
"draft_m": "Dybgang (m)",
|
||||
"air_draft_m": "Højde (m)",
|
||||
"invalid_metric": "Ugyldig numerisk værdi - indtast venligst meter som et decimaltal (f.eks. 12,5).",
|
||||
"port": "Hjemmehavn",
|
||||
"owner": "Ejer",
|
||||
"charter": "Charterselskab",
|
||||
"registration": "Nummerplade/registreringsnummer",
|
||||
"callsign": "Radiokaldesignal",
|
||||
"atis": "ATIS nr.",
|
||||
"mmsi": "MMSI-nr.",
|
||||
"save": "Gem skibsdata",
|
||||
"saving": "Vil blive reddet...",
|
||||
"saved": "Skibsdata er gemt med succes!",
|
||||
"loading": "Skibsdata er indlæst...",
|
||||
"sails_list": "Sejl (eksisterende sejl)",
|
||||
"sails_help": "Indtast de sejl, der er tilgængelige på din båd her (f.eks. storsejl, genua, fok).",
|
||||
"add_sail": "Tilføj sejl",
|
||||
"sail_name_placeholder": "z. f.eks. storsejl",
|
||||
"no_sails": "Ingen sejl opbevaret.",
|
||||
"photo_add": "Tilføj foto",
|
||||
"photo_change": "Skift foto",
|
||||
"photo_delete": "Slet foto",
|
||||
"tanks_section": "Tanke (kapacitet)",
|
||||
"tanks_help": "Valgfrit i liter - muliggør slider i journalen for kendte tankstørrelser.",
|
||||
"freshwater_capacity_l": "Drikkevand (liter)",
|
||||
"fuel_capacity_l": "Brændstof (liter)",
|
||||
"greywater_capacity_l": "Gråt vand (liter)",
|
||||
"invalid_tank_liters": "Ugyldig numerisk værdi - indtast venligst liter som et tal (f.eks. 200)."
|
||||
},
|
||||
"logs": {
|
||||
"title": "Logbogsdagbog",
|
||||
"new_entry": "Ny rejsedag",
|
||||
"travel_details": "Detaljer om rejsen",
|
||||
"add_event": "Tilføj ny logbogspost",
|
||||
"add_event_btn": "Tilføj begivenhed",
|
||||
"edit_event": "Rediger begivenhed",
|
||||
"save_event_btn": "Gem ændring",
|
||||
"cancel_event_edit": "Annuller",
|
||||
"delete_event": "Slet begivenhed",
|
||||
"sign_cleared_skipper_re_sign_title": "Skippers underskrift fjernet",
|
||||
"sign_cleared_skipper_re_sign": "Hændelsesloggen er blevet ændret. Skipperens underskrift er blevet fjernet. Godkend venligst igen.",
|
||||
"date": "dato",
|
||||
"day_of_travel": "Rejsedag / rejsedag",
|
||||
"departure": "Starthavn (rejse fra)",
|
||||
"destination": "Destinationsport (til)",
|
||||
"route": "Rejse fra/til",
|
||||
"freshwater": "Ferskvand (liter)",
|
||||
"fuel": "Treibstoff / Brændstof (liter)",
|
||||
"greywater": "Gråt vand (liter)",
|
||||
"greywater_level": "Fyldningsniveau",
|
||||
"tank_slider_of_max": "{{current}} / {{max}} L",
|
||||
"tank_capacity_tooltip": "Hvis tankkapaciteten (liter) er gemt i skibsdataene, kan du indtaste fyldningsniveauerne her ved hjælp af skyderen.",
|
||||
"morning": "Stå op om morgenen",
|
||||
"refilled": "Genopfyldt",
|
||||
"evening": "Stand om aftenen",
|
||||
"consumption": "Dagligt forbrug",
|
||||
"signatures": "Underskrifter / frigivelse",
|
||||
"sign_skipper": "Skippers underskrift",
|
||||
"sign_crew": "Crew-signatur",
|
||||
"sign_hint": "Tegn med finger, pen eller mus",
|
||||
"sign_clear": "Sletning",
|
||||
"sign_export_image": "[Underskrift]",
|
||||
"sign_with_passkey": "Frigør med Passkey.",
|
||||
"sign_passkey_signing": "Der anmodes om Passkey...",
|
||||
"sign_passkey_signed": "Udgivet af {{username}}",
|
||||
"sign_passkey_export": "Passkey: {{username}} ({{date}})",
|
||||
"sign_attribution_export": "{{username}} ({{date}})",
|
||||
"sign_passkey_clear": "Fjern Passkey-frigivelse",
|
||||
"sign_mode_passkey": "Passkey",
|
||||
"sign_mode_classic": "Klassisk",
|
||||
"sign_passkey_failed": "Passkey Frigivelse mislykkedes",
|
||||
"sign_passkey_cancelled": "Passkey Frigivelse annulleret",
|
||||
"sign_invalid": "Signatur ugyldig - indholdet er blevet ændret",
|
||||
"sign_badge_skipper": "Skipper",
|
||||
"sign_badge_skipper_invalid": "Ugyldig",
|
||||
"sign_badge_skipper_title_valid": "Skipper har udgivet",
|
||||
"sign_badge_skipper_title_invalid": "Skippers signatur er ugyldig - indholdet er blevet ændret",
|
||||
"sign_classic_or_passkey": "Valgfrit: klassisk underskrift eller Passkey-frigivelse ovenfor",
|
||||
"sign_crew_passkey_hint": "Besætningsmedlemmer med skriveadgang kan frigive via Passkey.",
|
||||
"sign_offline_hint": "Passkey-Godkendelse kræver internet - klassisk underskrift mulig offline",
|
||||
"sign_lock_notice": "Efter underskrivelsen kan der ikke foretages ændringer i logbogen (undtagen fotos), uden at skipper og besætning skal skrive under igen.",
|
||||
"sign_lock_active": "Denne post er underskrevet. Ændringer i logbogen (undtagen fotos) fjerner automatisk skipperens og besætningens underskrifter.",
|
||||
"sign_lock_warning_title": "Bekræft underskrift",
|
||||
"sign_lock_warning": "Efter underskrivelsen er det ikke længere muligt at foretage ændringer i logbogen (undtagen fotos), uden at skipper og besætning skal skrive under igen.\n\nVil du gerne fortsætte?",
|
||||
"sign_proceed": "Tegn",
|
||||
"sign_cancel": "Annuller",
|
||||
"sign_cleared_re_sign_title": "Underskrifter fjernet",
|
||||
"sign_cleared_re_sign": "Logbogsoptegnelsen er blevet ændret. Skipperens og besætningens underskrifter er blevet fjernet. Underskriv venligst igen.",
|
||||
"no_entries": "Ingen logbogsposter fundet for denne yacht. Opret din første rejsedag!",
|
||||
"back_to_list": "Tilbage til tidsskriftslisten",
|
||||
"save": "Gem logbogsside",
|
||||
"saving": "Vil blive reddet...",
|
||||
"saved": "Logbogsside gemt med succes!",
|
||||
"loading": "Dagbogen er ved at blive indlæst.",
|
||||
"view_mode_label": "Visning",
|
||||
"view_list": "Liste",
|
||||
"live_mode": "Live",
|
||||
"live_title": "Live-journal",
|
||||
"live_loading": "Live-journal indlæses...",
|
||||
"live_retry": "Prøv igen",
|
||||
"live_load_error": "Live-journal kunne ikke indlæses.",
|
||||
"live_action_error": "Indtastning kunne ikke gemmes.",
|
||||
"live_open_editor": "Fuld editor",
|
||||
"live_actions_label": "Hurtighandlinger",
|
||||
"live_stream_label": "Hændelseslog",
|
||||
"live_stream_title": "Journal",
|
||||
"live_no_events": "Ingen indtastninger endnu — tryk på en handling.",
|
||||
"live_motor_start": "Motor Start",
|
||||
"live_motor_stop": "Motor Stop",
|
||||
"live_cast_off": "Afsejling",
|
||||
"live_moor": "Anløb",
|
||||
"live_sails_btn": "Sejl",
|
||||
"live_sails_pick": "Vælg sejl",
|
||||
"live_sails_pick_hint": "Tryk på flere sejl (tryk igen for at fravælge), og indtast derefter.",
|
||||
"live_sails_selected": "Valgt: {{sails}}",
|
||||
"live_sails_confirm": "Indtast",
|
||||
"live_sails_confirm_count": "Indtast ({{count}})",
|
||||
"live_sails": "Sejl: {{sails}}",
|
||||
"live_fix": "Fix",
|
||||
"live_fix_coords": "Fix {{lat}}, {{lng}}",
|
||||
"live_fix_manual_hint": "GPS ikke tilgængelig. Indtast bredde- og længdegrad manuelt, eller prøv igen med GPS-knappen.",
|
||||
"live_fix_gps_loading": "Henter GPS-position…",
|
||||
"live_fix_invalid": "Indtast gyldige koordinater (bredde −90…90, længde −180…180).",
|
||||
"live_fix_lat_placeholder": "Bredde (Lat)",
|
||||
"live_fix_lng_placeholder": "Længde (Lng)",
|
||||
"live_photo_btn": "Foto (kamera)",
|
||||
"live_photo_capture_btn": "Tag billede",
|
||||
"live_photo_save_btn": "Gem",
|
||||
"live_photo_retake_btn": "Tag igen",
|
||||
"live_photo_capture_failed": "Optagelse mislykkedes. Prøv igen.",
|
||||
"live_photo_open_camera_btn": "Åbn kamera",
|
||||
"live_photo_native_hint": "Tag et foto med enhedens kamera og gem det her bagefter.",
|
||||
"live_photo_camera_starting": "Starter kamera…",
|
||||
"live_photo_camera_denied": "Kameraadgang nægtet eller utilgængelig.",
|
||||
"live_photo_camera_unavailable": "Kamera understøttes ikke i denne browser.",
|
||||
"live_photo_error": "Foto kunne ikke gemmes.",
|
||||
"live_photo_entry": "Foto: {{caption}}",
|
||||
"live_photo_entry_plain": "Foto taget",
|
||||
"live_undo_photo_hint": "Foto gemt",
|
||||
"live_comment_btn": "Kommentar",
|
||||
"live_comment_placeholder": "Indtast tekst…",
|
||||
"live_comment_confirm": "Indtast",
|
||||
"live_gps_error": "GPS-position kunne ikke bestemmes.",
|
||||
"live_event_generic": "Hændelse",
|
||||
"live_weather_btn": "Vejr",
|
||||
"live_weather_owm_btn": "Hent OpenWeatherMap-vejr",
|
||||
"live_weather_owm_loading": "Henter vejr…",
|
||||
"live_weather_fix_required": "Log først en GPS-fix (Fix-knap) for at hente OpenWeatherMap-vejr. Positionen må højst være 6 timer gammel.",
|
||||
"live_weather_fix_stale": "Den seneste GPS-fix er ældre end 6 timer. Log en ny fix, før du henter vejr.",
|
||||
"live_wind_btn": "Vind",
|
||||
"live_temp_btn": "T °C",
|
||||
"live_pressure_btn": "Lufttryk",
|
||||
"live_precip_btn": "Nedbør",
|
||||
"live_sea_state_btn": "Søgang",
|
||||
"live_course_btn": "Kurs",
|
||||
"live_fuel_btn": "Diesel",
|
||||
"live_water_btn": "Vand",
|
||||
"live_wind_entry": "Vind {{value}}",
|
||||
"live_temp_entry": "Temperatur {{temp}} °C",
|
||||
"live_pressure_entry": "Lufttryk {{value}} hPa",
|
||||
"live_precip_entry": "Nedbør {{value}}",
|
||||
"live_sea_state_entry": "Søgang {{value}}",
|
||||
"live_course_entry": "Kurs {{course}}",
|
||||
"live_fuel_entry": "Diesel +{{liters}} L",
|
||||
"live_water_entry": "Vand +{{liters}} L",
|
||||
"live_auto_position": "Auto-position",
|
||||
"live_undo_hint": "Indtastning gemt",
|
||||
"live_undo_btn": "Fortryd",
|
||||
"live_pressure_placeholder": "f.eks. 1013",
|
||||
"live_temp_placeholder": "f.eks. 18",
|
||||
"live_precip_placeholder": "f.eks. let regn",
|
||||
"live_sea_state_placeholder": "f.eks. 3",
|
||||
"live_course_placeholder": "f.eks. 245",
|
||||
"live_fuel_placeholder": "Optankede liter",
|
||||
"live_water_placeholder": "Optankede liter",
|
||||
"live_sog_btn": "SOG",
|
||||
"live_stw_btn": "STW",
|
||||
"live_sog_entry": "SOG {{speed}} kn",
|
||||
"live_stw_entry": "STW {{speed}} kn",
|
||||
"live_sog_placeholder": "f.eks. 5,2",
|
||||
"live_stw_placeholder": "f.eks. 4,8",
|
||||
"live_sog_hint": "Fart over grund (kn) — GPS-værdi forudfyldes, hvis tilgængelig.",
|
||||
"delete_entry": "Slet tag",
|
||||
"delete_confirm": "Er du sikker på, at du vil slette denne rejsedag permanent?",
|
||||
"carry_over_tanks_title": "Overføre data fra den foregående dag?",
|
||||
"carry_over_tanks_confirm": "Overtage starthavn, ferskvand, brændstof og gråvand fra den sidste dag på turen?\n\nStarthavn: {{departure}}\nFerskvand: {{fw}} L\nBrændstof: {{fuel}} L\nGråt vand: {{greywater}} L",
|
||||
"carry_over_tanks_yes": "Tag over",
|
||||
"carry_over_tanks_no": "Start med 0",
|
||||
"event_title": "Kronologisk hændelseslog",
|
||||
"no_events": "Der er endnu ikke indtastet nogen begivenheder for denne rejsedag.",
|
||||
"event_time": "Tidspunkt på dagen",
|
||||
"event_mgk": "MgK-kursus",
|
||||
"event_rwk": "RwK-kursus",
|
||||
"event_course_section": "Kursus",
|
||||
"course_dial_hint": "Drej ringen eller indtast grader",
|
||||
"course_dial_step_label": "Trinstørrelse",
|
||||
"course_step_fine": "1°",
|
||||
"course_step_medium": "5°",
|
||||
"course_step_coarse": "10°",
|
||||
"course_tab_mgk": "MgK",
|
||||
"course_tab_rwk": "rwK",
|
||||
"course_invalid": "Ugyldigt kursus (0-360)",
|
||||
"course_placeholder_degrees": "z. B. 180",
|
||||
"course_placeholder_cardinal": "z. E.G. NW",
|
||||
"compass_n": "N",
|
||||
"compass_e": "O",
|
||||
"compass_s": "S",
|
||||
"compass_w": "W",
|
||||
"wind_mode_cardinal": "Kardinal",
|
||||
"wind_mode_degrees": "Som grad",
|
||||
"event_wind_direction": "Vindretning",
|
||||
"event_wind_strength": "Vindstyrke",
|
||||
"event_sea_state": "Havets tilstand",
|
||||
"event_weather": "Vejret",
|
||||
"event_log": "Log (sm)",
|
||||
"event_gps": "GPS-position",
|
||||
"event_location": "Sted/havn",
|
||||
"event_location_placeholder": "z. f.eks. Kiel",
|
||||
"event_remarks": "Bemærkninger / hændelser",
|
||||
"gps_btn": "Hent GPS-koordinater",
|
||||
"weather_btn": "OpenWeatherMap Kald vejret op",
|
||||
"event_wind_pressure": "Lufttryk (hPa)",
|
||||
"event_heel": "Krængning (°)",
|
||||
"event_sails": "Sejlhåndtering/motor",
|
||||
"motor_propulsion": "Kørsel med maskine",
|
||||
"sails_picker_show_more": "Vis alle sejl",
|
||||
"sails_picker_show_less": "Vis mindre",
|
||||
"motor_hours": "Maskintimer (i alt)",
|
||||
"fuel_per_motor_hour": "Forbrug pr. maskintime",
|
||||
"event_distance": "Afstand (nm)",
|
||||
"export_csv": "Download CSV.",
|
||||
"share_csv": "CSV andel",
|
||||
"export_pdf": "Download PDF.",
|
||||
"exporting_pdf": "PDF er genereret...",
|
||||
"photos_title": "Vedhæftede billeder (E2E-krypteret)",
|
||||
"photo_caption_label": "Fotobeskrivelse/etiket (valgfrit)",
|
||||
"photo_caption_placeholder": "f.eks. at sætte sejl nær indsejlingen til havnen",
|
||||
"photo_btn": "Tag foto / upload",
|
||||
"photo_processing": "Er ved at blive behandlet...",
|
||||
"no_photos": "Der er endnu ingen billeder knyttet til denne rejsedag.",
|
||||
"photo_delete_confirm": "Er du sikker på, at du vil slette dette foto permanent?",
|
||||
"confirm_yes": "Ja",
|
||||
"confirm_no": "Nej",
|
||||
"track_upload_title": "GPS-spor (fil)",
|
||||
"track_upload_points": "Point",
|
||||
"gps_tracking_btn_gpx": "Download sporfilen",
|
||||
"gps_track_upload_help": "Træk en GPX-, KML- eller GeoJSON-fil hertil, eller klik for at vælge",
|
||||
"gps_track_upload_btn": "Upload GPS-spor",
|
||||
"gps_track_delete": "Slet sporfilen",
|
||||
"gps_track_delete_confirm": "Er du sikker på, at du vil slette denne sporfil permanent?",
|
||||
"track_distance": "GPS-rute (sm)",
|
||||
"track_speed_max": "Maks. Hastighed (kn)",
|
||||
"track_speed_avg": "Ø Hastighed (kn)",
|
||||
"track_map_title": "GPS-spor på OpenSeaMap",
|
||||
"track_map_start": "Start",
|
||||
"track_map_end": "Mål",
|
||||
"track_map_speed_slow": "langsomt",
|
||||
"track_map_speed_fast": "hurtigt",
|
||||
"track_map_error": "Kortet kunne ikke indlæses.",
|
||||
"exporting": "Eksport...",
|
||||
"share_unsupported": "Deling understøttes ikke på denne enhed. Filen er blevet downloadet i stedet.",
|
||||
"invite_crew": "Inviter besætningen",
|
||||
"invite_link_copied": "Invitationslink kopieret til udklipsholderen!",
|
||||
"invite_link_desc": "Del dette link med besætningsmedlemmer for at give dem skriveadgang til denne logbog.",
|
||||
"collaborators_list": "Medlemmer / besætning",
|
||||
"revoke": "Fjerne",
|
||||
"revoke_confirm": "Er du sikker på, at du vil tilbagekalde dette besætningsmedlems adgang?",
|
||||
"invite_role": "Rolle",
|
||||
"invite_expires": "Linket er gyldigt i 48 timer",
|
||||
"nmea_import_title": "Import NMEA log",
|
||||
"nmea_import_intro": "Upload a .nmea file from your onboard logger. The app suggests journal entries — you choose what to import.",
|
||||
"nmea_import_btn": "Import NMEA",
|
||||
"nmea_file_label": "NMEA file",
|
||||
"nmea_stats": "{{lines}} sentences parsed · types: {{types}}",
|
||||
"nmea_warn_no_position": "No position sentences found — track and GPS fields may stay empty.",
|
||||
"nmea_mode_label": "Generate journal entries",
|
||||
"nmea_mode_interval": "By time interval",
|
||||
"nmea_mode_change": "On significant change",
|
||||
"nmea_mode_both": "Both (merge)",
|
||||
"nmea_interval_label": "Interval (minutes)",
|
||||
"nmea_import_track": "Import GPS track from NMEA",
|
||||
"nmea_preview": "Preview",
|
||||
"nmea_preview_hint": "{{count}} suggested journal entries",
|
||||
"nmea_select_all": "Select all",
|
||||
"nmea_select_none": "Select none",
|
||||
"nmea_source_interval": "Interval",
|
||||
"nmea_source_change": "Event",
|
||||
"nmea_apply": "Apply to journal",
|
||||
"nmea_back": "Back",
|
||||
"nmea_cancel": "Cancel",
|
||||
"nmea_archive_question": "Archive raw log locally? (This device only, not synced.)",
|
||||
"nmea_archive_keep": "Archive",
|
||||
"nmea_archive_discard": "Discard",
|
||||
"nmea_archive_stored": "NMEA archived: {{name}}",
|
||||
"nmea_archive_delete_confirm": "Delete archived NMEA log from this device?",
|
||||
"nmea_error_no_samples": "No usable NMEA sentences in the file.",
|
||||
"nmea_error_parse": "Could not read NMEA file.",
|
||||
"nmea_error_read": "Could not read file.",
|
||||
"nmea_error_no_file": "Please choose an NMEA file first.",
|
||||
"nmea_error_no_selection": "Please select at least one journal entry.",
|
||||
"nmea_remark_interval": "NMEA interval",
|
||||
"nmea_remark_uncertain": "uncertain",
|
||||
"nmea_remark_depth": "Depth {{depth}} m",
|
||||
"nmea_change_course": "Course change {{from}}° → {{to}}°",
|
||||
"nmea_change_wind": "Wind {{from}}° → {{to}}°",
|
||||
"nmea_change_wind_speed": "Wind {{from}} → {{to}} kn",
|
||||
"nmea_change_pressure": "Pressure {{from}} → {{to}} hPa",
|
||||
"nmea_change_depth": "Depth {{from}} → {{to}} m",
|
||||
"nmea_change_engine_start": "Engine on ({{rpm}} rpm)",
|
||||
"nmea_change_engine_stop": "Engine off",
|
||||
"nmea_change_autopilot_on": "Autopilot on",
|
||||
"nmea_change_autopilot_off": "Autopilot off",
|
||||
"nmea_change_gps_lost": "GPS fix lost",
|
||||
"nmea_change_gps_regained": "GPS fix restored",
|
||||
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
|
||||
"nmea_change_departure": "Departure / underway",
|
||||
"nmea_change_anchor": "Anchored / stop",
|
||||
"nmea_change_speed": "Speed {{from}} → {{to}} kn",
|
||||
"nmea_warn_duplicate_file": "This NMEA file has already been imported. Importing the same file again will add duplicate journal entries."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dine logbøger",
|
||||
"subtitle": "Vælg en logbog, eller opret en ny til at styre dine rejser.",
|
||||
"create_btn": "Opret logbog",
|
||||
"new_logbook_placeholder": "Navn på logbog eller yacht",
|
||||
"logout": "Log ud",
|
||||
"logged_in_as": "Logget ind som {{name}}",
|
||||
"delete_confirm": "Er du sikker på, at du vil slette denne logbog permanent? Alle lokale data og serverkopier vil blive destrueret.\n\nTip: Lav en sikkerhedskopi (.daagbok.json) på forhånd under Indstillinger → Sikkerhedskopiering og gendannelse, hvis du vil beholde dataene senere.",
|
||||
"no_logbooks": "Ingen logbøger fundet. Opret din første logbog for at komme i gang!",
|
||||
"loading": "Logbøgerne er fyldt op...",
|
||||
"status_synced": "Synkroniseret",
|
||||
"status_local": "Kun lokal cache",
|
||||
"delete_btn": "Slet logbog",
|
||||
"section_owned": "Mine logbøger",
|
||||
"section_shared": "Fælles logbøger",
|
||||
"section_shared_hint": "Du er blevet inviteret som besætningsmedlem. Skipperprofil og indstillinger tilhører ejeren.",
|
||||
"role_owner": "Egen logbog",
|
||||
"role_owner_hint": "Du er ejer og skipper af denne logbog",
|
||||
"role_crew": "Adgang for besætning",
|
||||
"role_crew_hint": "Inviteret logbog - du kan arbejde som besætning og underskrive den",
|
||||
"role_read": "Læs kun",
|
||||
"role_read_hint": "Opdelt logbog - kun visning, ingen redigering",
|
||||
"open_profile": "Åben profil af {{name}}",
|
||||
"edit_title": "Omdøb logbog",
|
||||
"edit_placeholder": "Nyt navn på logbogen",
|
||||
"edit_success": "Logbog omdøbt med succes",
|
||||
"edit_btn": "Omdøb",
|
||||
"filter_label": "Filtrer logbøger",
|
||||
"filter_placeholder": "Navn, årstal eller dato ...",
|
||||
"filter_clear": "Nulstil filter",
|
||||
"filter_results": "{{count}} Hits",
|
||||
"filter_no_results": "Ingen logbøger matcher din søgning. Prøv med et andet navn eller et andet år.",
|
||||
"sort_label": "Sortere",
|
||||
"sort_by_label": "Sorter efter",
|
||||
"sort_by_name": "Navn",
|
||||
"sort_by_date": "dato",
|
||||
"sort_dir_label": "Sekvens",
|
||||
"sort_asc": "Stigende",
|
||||
"sort_desc": "Nedadgående",
|
||||
"sort_name_asc": "Navn A til Z",
|
||||
"sort_name_desc": "Navn Z til A",
|
||||
"sort_date_asc": "Ældste først",
|
||||
"sort_date_desc": "Nyeste først"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Brugerprofil",
|
||||
"subtitle": "Konto, Passkeys og statistik for {{name}}.",
|
||||
"back": "Tilbage til instrumentbrættet",
|
||||
"loading": "Profilen er ved at blive indlæst...",
|
||||
"load_error": "Profilen kunne ikke indlæses.",
|
||||
"copy_failed": "Kopiering mislykkedes.",
|
||||
"processing": "Er ved at blive behandlet...",
|
||||
"identity_title": "Konto-identitet",
|
||||
"username": "Brugernavn",
|
||||
"user_id": "Bruger-ID",
|
||||
"copy_user_id": "Kopier bruger-ID",
|
||||
"account_since": "Konto siden",
|
||||
"prf_status": "Passkey nøgleafledning (PRF)",
|
||||
"prf_active": "Aktiv",
|
||||
"prf_inactive": "Ikke sat op",
|
||||
"passkeys_title": "Passkeys",
|
||||
"passkeys_desc": "Registrer en separat Passkey på hver enhed. På den måde kan du logge ind, selv når du har skiftet platform.",
|
||||
"passkeys_empty": "Ingen Passkeys fundet.",
|
||||
"add_passkey_btn": "Tilføj ny Passkey.",
|
||||
"add_passkey_success": "Passkey tilføjet med succes.",
|
||||
"add_passkey_failed": "Passkey kunne ikke tilføjes.",
|
||||
"remove_passkey_btn": "Fjern Passkey",
|
||||
"remove_passkey_last_title": "Sidste Passkey.",
|
||||
"remove_passkey_last_desc": "Den eneste Passkey kan ikke fjernes uden at miste adgangen til din konto. Hvis du vil slette kontoen helt, skal du bruge farezonen nederst på denne side.",
|
||||
"remove_passkey_failed": "Passkey kunne ikke fjernes.",
|
||||
"remove_passkey_confirm_title": "Fjern Passkey?",
|
||||
"remove_passkey_confirm_desc": "Denne enhed kan så ikke længere logge ind med denne Passkey.",
|
||||
"remove_passkey_confirm_yes": "Fjerne",
|
||||
"remove_passkey_confirm_no": "Annuller",
|
||||
"pin_title": "Lokal PIN-kode",
|
||||
"pin_status": "Status",
|
||||
"pin_active": "Aktiv på denne enhed",
|
||||
"pin_inactive": "Ikke sat op",
|
||||
"pin_confirm_label": "Bekræft PIN-kode",
|
||||
"pin_confirm_placeholder": "Indtast PIN-kode igen",
|
||||
"pin_set_btn": "Opsæt PIN-kode",
|
||||
"pin_change_btn": "Skift PIN-kode",
|
||||
"pin_remove_btn": "Fjern PIN-kode",
|
||||
"pin_saved": "PIN-kode gemt.",
|
||||
"pin_save_failed": "PIN-koden kunne ikke gemmes.",
|
||||
"pin_mismatch": "PIN-koderne stemmer ikke overens.",
|
||||
"pin_length_error": "PIN-koden skal bestå af mindst 4 tegn.",
|
||||
"pin_no_session": "Sessionen er udløbet - tilmeld dig venligst igen.",
|
||||
"remove_pin_confirm_title": "Fjerne PIN-kode?",
|
||||
"remove_pin_confirm_desc": "Du skal logge ind igen på denne enhed med Passkey eller genoprettelsesnøglen.",
|
||||
"remove_pin_confirm_yes": "Fjern PIN-kode",
|
||||
"remove_pin_confirm_no": "Annuller",
|
||||
"security_title": "Tjekliste for sikkerhed",
|
||||
"security_desc": "Oversigt over de vigtigste beskyttelsesmekanismer for din konto.",
|
||||
"security_passkeys_ok": "Mindst én Passkey registreret",
|
||||
"security_passkeys_missing": "Nej Passkey registreret",
|
||||
"security_prf_ok": "PRF-nøgleafledning aktiv",
|
||||
"security_prf_missing": "PRF ikke sat op",
|
||||
"security_pin_ok": "Lokal PIN-kode på denne enhed",
|
||||
"security_pin_missing": "Ingen lokal PIN-kode",
|
||||
"security_recovery_ok": "Opsætning af genoprettelsesnøgle",
|
||||
"security_recovery_hint": "De 12 ord blev vist under registreringen. Opbevar dem offline og adskilt fra enheden. Du kan oprette en ny nøgle nedenfor - den gamle bliver så ugyldig.",
|
||||
"recovery_rotate_btn": "Opret en ny genoprettelsesnøgle",
|
||||
"recovery_rotate_confirm_title": "Opret en ny genoprettelsesnøgle?",
|
||||
"recovery_rotate_confirm_desc": "Den tidligere nøgle på 12 ord bliver ugyldig med det samme. Sørg for at opbevare den nye nøgle sikkert, før du fortsætter.",
|
||||
"recovery_rotate_confirm_yes": "Opret ny nøgle",
|
||||
"recovery_rotate_confirm_no": "Annuller",
|
||||
"recovery_rotate_new_warning": "VIGTIGT: Skriv disse 12 ord ned, og opbevar dem offline. Den tidligere genoprettelsesnøgle er nu ugyldig.",
|
||||
"recovery_rotate_failed": "Genoprettelsesnøglen kunne ikke oprettes.",
|
||||
"recovery_rotate_no_session": "Krypteringssessionen er udløbet - log ud og log ind igen, og prøv så igen.",
|
||||
"device_title": "Denne enhed",
|
||||
"device_desc": "Lokal cache, synkroniseringsstatus og hurtig login i denne browser.",
|
||||
"device_sync_pending": "{{count}} ventende synkroniseringsposter",
|
||||
"device_sync_ok": "Alle lokale ændringer synkroniseres",
|
||||
"device_remembered": "Konto til hurtigt login gemt på denne enhed",
|
||||
"device_not_remembered": "Kontoen er ikke på listen over hurtige login",
|
||||
"device_forget_btn": "Glemt konto på denne enhed",
|
||||
"device_forget_confirm_title": "Fjerne hurtig login?",
|
||||
"device_forget_confirm_desc": "Kontoen forsvinder fra listen over hurtige login på denne enhed. Din session og dine lokale logbøger bevares.",
|
||||
"device_forget_confirm_yes": "Fjerne",
|
||||
"device_forget_confirm_no": "Annuller",
|
||||
"passkey_label": "Navn på ny Passkey (valgfrit)",
|
||||
"passkey_label_placeholder": "z. f.eks. MacBook, iPhone.",
|
||||
"passkey_rename_btn": "Gem navn",
|
||||
"passkey_rename_success": "Passkey navn gemt.",
|
||||
"passkey_rename_failed": "Passkey-Navnet kunne ikke gemmes.",
|
||||
"passkey_unnamed": "Uden titel Passkey.",
|
||||
"stats_title": "Statistik",
|
||||
"stats_subtitle": "Om alle dine logbøger på denne enhed",
|
||||
"stats_logbooks": "Logbøger",
|
||||
"stats_account_since": "Konto siden",
|
||||
"stats_shared_logbooks": "Fælles logbøger",
|
||||
"appearance_title": "App og visualisering",
|
||||
"appearance_desc": "Designet og farveskemaet gælder for hele appen på denne enhed.",
|
||||
"theme_label": "Appens designstil",
|
||||
"theme_auto": "Automatisk (OS-registrering)",
|
||||
"theme_ocean": "Ocean (glasmorfisme)",
|
||||
"theme_material": "Materiale (Android)",
|
||||
"theme_cupertino": "Cupertino (iOS)",
|
||||
"color_scheme_label": "Lys eller mørk tilstand",
|
||||
"color_scheme_auto": "Automatisk (system)",
|
||||
"color_scheme_light": "Lys",
|
||||
"color_scheme_dark": "Mørk",
|
||||
"integrations_title": "Integrationer",
|
||||
"owm_key": "OpenWeatherMap API-nøgle",
|
||||
"owm_help": "Valgfrit: egen OpenWeatherMap API-nøgle. Hvis der ikke er nogen indtastning, bruges nøglen på serversiden fra operatørkonfigurationen.",
|
||||
"prefs_save": "Gemme",
|
||||
"prefs_saving": "Vil blive reddet...",
|
||||
"prefs_saved": "Gemt",
|
||||
"tour_title": "App-tur",
|
||||
"tour_desc": "Lad dig guide gennem de vigtigste områder i appen igen.",
|
||||
"tour_restart": "Start turen igen",
|
||||
"push_title": "Push-meddelelser",
|
||||
"push_desc": "Som logbogsejer får du besked, når inviterede besætningsmedlemmer synkroniserer ændringer. Intet indhold overføres i ren tekst.",
|
||||
"push_enable": "Giv os besked om ændringer i besætningen",
|
||||
"push_active": "Push-meddelelser er aktive på denne enhed.",
|
||||
"push_unsupported": "Push-meddelelser understøttes ikke i denne browser.",
|
||||
"push_denied_hint": "Notifikationer er blokeret. Tillad dem i browserens eller enhedens indstillinger.",
|
||||
"push_ios_install_hint": "På iPhone/iPad: Føj app til startskærmen (iOS 16.4+) for at bruge push.",
|
||||
"push_error": "Push-meddelelser kunne ikke aktiveres."
|
||||
},
|
||||
"crew": {
|
||||
"title": "Skipper- og besætningsprofiler",
|
||||
"skipper_section": "Skipper-profil",
|
||||
"skipper_read_only_hint": "Skipperprofilen kan kun redigeres af logbogens ejer.",
|
||||
"crew_section": "Besætningsliste",
|
||||
"add_crew": "Tilføj besætningsmedlem",
|
||||
"edit_crew": "Rediger besætningsmedlem",
|
||||
"no_crew": "Ingen besætningsmedlemmer tilføjet endnu.",
|
||||
"max_crew": "Det maksimale antal på 5 besætningsmedlemmer er nået.",
|
||||
"name": "Navn",
|
||||
"address": "adresse",
|
||||
"birthdate": "Fødselsdag",
|
||||
"phone": "Telefonnummer",
|
||||
"nationality": "Nationalitet",
|
||||
"passport": "Pas/ID-nummer",
|
||||
"bloodtype": "Blodgruppe",
|
||||
"allergies": "Allergier",
|
||||
"diseases": "Eksisterende tilstande/sygdomme",
|
||||
"save": "Gem skipper-data",
|
||||
"save_member": "Gem medlem",
|
||||
"saved": "Skipperprofilen er blevet gemt!",
|
||||
"loading": "Besætningsfilerne er indlæst.",
|
||||
"delete_confirm": "Er du sikker på, at du vil fjerne dette besætningsmedlem?"
|
||||
},
|
||||
"deviation": {
|
||||
"title": "Tabel over kompasafvigelser",
|
||||
"subtitle": "Indtast den magnetiske kompasafbøjning (afbøjning) for kurser (MgK) fra 000° til 360° i trin på 10°.",
|
||||
"heading": "MgK",
|
||||
"deviation": "Distraktion",
|
||||
"save": "Gem kalibreringsgitter",
|
||||
"saving": "Vil blive reddet...",
|
||||
"saved": "Kalibreringsgitteret er gemt med succes!",
|
||||
"loading": "Kalibreringstabellen er indlæst..."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Indstillinger for logbog",
|
||||
"subtitle": "Del, tag backup og samarbejd om denne logbog.",
|
||||
"select_logbook_hint": "Vælg en logbog for at redigere dens indstillinger.",
|
||||
"no_key": "Ingen OpenWeatherMap API-nøgle tilgængelig. Gem din egen nøgle i brugerprofilen, eller kontakt operatøren.",
|
||||
"weather_success": "Vejrdata hentet med succes!",
|
||||
"weather_error": "Hentning af vejrdata mislykkedes. Tjek API-nøglen og forbindelsen.",
|
||||
"weather_date_mismatch": "Vejrdata kan kun hentes for i dag ({{today}}). Dette logbogsindlæg er dateret {{date}}.",
|
||||
"gps_error": "Indtast en placering, eller find GPS-koordinaterne.",
|
||||
"share_title": "Del logbog (skrivebeskyttet)",
|
||||
"share_desc": "Aktivér denne mulighed for at oprette et offentligt, skrivebeskyttet link. Alle med linket kan se dine rejser, yachtprofiler og besætning. Krypteringsnøglerne overføres aldrig til serveren (de forbliver i hash-delen af URL'en).",
|
||||
"share_privacy_warning": "Anbefaling: Del kun dette link privat (f.eks. via e-mail eller messenger), ikke på sociale medier.",
|
||||
"share_enable": "Aktivér offentligt link",
|
||||
"share_copied": "Link kopieret!",
|
||||
"share_copy_btn": "Kopier link",
|
||||
"link_qr_hint": "Scan QR-koden med din telefon",
|
||||
"link_qr_alt": "QR-kode til linket",
|
||||
"danger_zone_title": "Farezone",
|
||||
"danger_zone_desc": "Når du sletter din konto, slettes alle dine Passkey'er, logbøger, skibsdata, besætningsprofiler, rejseindlæg og E2E-nøgler uigenkaldeligt. Denne handling kan ikke fortrydes.",
|
||||
"delete_account_btn": "Slet konto uigenkaldeligt",
|
||||
"delete_account_confirm_title": "Slette konto?",
|
||||
"delete_account_confirm_desc": "Er du helt sikker på, at du vil slette din konto uigenkaldeligt og alle tilknyttede logbøger og E2E-krypterede data?",
|
||||
"delete_account_confirm_yes": "Ja, slet konto og alle data",
|
||||
"delete_account_confirm_no": "Annuller",
|
||||
"delete_account_failed": "Kontoen kunne ikke slettes. Prøv venligst igen.",
|
||||
"delete_backup_hint": "Tip: Lav sikkerhedskopier af dine logbøger (.daagbok.json) i indstillingerne for hver logbog, før du sletter dem.",
|
||||
"deleting_account": "Kontoen vil blive slettet...",
|
||||
"invite_push_prompt_title": "Aktivere push-meddelelser?",
|
||||
"invite_push_prompt_message": "Så snart inviterede besætningsmedlemmer synkroniserer ændringer, kan du blive informeret via push. Intet logbogsindhold sendes i almindelig tekst.",
|
||||
"invite_push_prompt_ios_message": "Så snart besætningsmedlemmerne synkroniserer ændringer, kan du blive informeret via push. På iPhone/iPad: Føj appen til startskærmen (iOS 16.4+), og aktiver derefter push i brugerprofilen.",
|
||||
"invite_push_prompt_enable": "Aktiver nu",
|
||||
"invite_push_prompt_later": "Senere",
|
||||
"invite_push_prompt_success": "Push-meddelelser er aktive på denne enhed.",
|
||||
"backup_title": "Sikkerhedskopiering og gendannelse",
|
||||
"backup_desc": "Komplet krypteret backup af denne logbog (poster, fotos, GPS-spor, besætning, skib). Beskyttet med backup-passphrase - til gendannelse til denne eller en ny konto.",
|
||||
"backup_export_title": "Opret backup",
|
||||
"backup_export_desc": "Downloader alle lokale data som .daagbok.json. Hold filen og adgangssætningen adskilt og sikker.",
|
||||
"backup_restore_title": "Gendan sikkerhedskopi",
|
||||
"backup_restore_desc": "Gendanner en sikkerhedskopi til din nuværende konto - selv efter registrering af en ny konto.",
|
||||
"backup_passphrase": "Backup-passphrase",
|
||||
"backup_passphrase_placeholder": "Mindst 8 tegn",
|
||||
"backup_passphrase_confirm": "Bekræft adgangssætning",
|
||||
"backup_passphrase_short": "Backup-passphrasen skal være mindst 8 tegn lang.",
|
||||
"backup_passphrase_mismatch": "Passphrases matcher ikke.",
|
||||
"backup_wrong_passphrase": "Passphrase forkert eller backup beskadiget.",
|
||||
"backup_export_btn": "Download backup",
|
||||
"backup_exporting": "Sikkerhedskopien er oprettet...",
|
||||
"backup_export_success": "Backup oprettet ({{count}} rejsedage).",
|
||||
"backup_file_label": "Backup-fil (.daagbok.json)",
|
||||
"backup_preview_btn": "Tjek indhold",
|
||||
"backup_previewing": "Tjek...",
|
||||
"backup_restore_btn": "Gendan",
|
||||
"backup_restoring": "Vil blive restaureret...",
|
||||
"backup_restore_success": "Logbog \"{{title}}\" er blevet gendannet.",
|
||||
"backup_restore_cancelled": "Genopretning aflyst.",
|
||||
"backup_invalid_json": "Filen er ikke en gyldig JSON-fil.",
|
||||
"backup_invalid_format": "Ukendt eller forældet backup-format.",
|
||||
"backup_not_owner": "Kun logbogens ejer kan oprette sikkerhedskopier.",
|
||||
"backup_not_authenticated": "Log ind for at gendanne en sikkerhedskopi.",
|
||||
"backup_id_conflict": "Der findes allerede en logbog med dette ID.",
|
||||
"backup_overwrite_confirm": "Den eksisterende logbog med samme ID erstattes. Fortsætter du?",
|
||||
"backup_new_id_confirm": "Importere backup'en som en ny logbog med et nyt ID?",
|
||||
"backup_stat_entries": "{{count}} Rejsedage",
|
||||
"backup_stat_photos": "{{count}} Fotos",
|
||||
"backup_stat_crew": "{{count}} Besætningens poster",
|
||||
"backup_stat_tracks": "{{count}} GPS-spor",
|
||||
"backup_exported_at": "Eksporteret: {{date}}"
|
||||
},
|
||||
"disclaimer": {
|
||||
"title": "Vigtige bemærkninger",
|
||||
"intro": "Læs venligst følgende instruktioner, før du bruger Kapteins Daagbok.",
|
||||
"e2e_title": "Ende-til-ende-kryptering",
|
||||
"e2e_body": "Dine logbogsdata er krypteret fra ende til anden. Kun du - eller personer med din nøgle - kan læse indholdet. Kun krypterede data gemmes på serveren.",
|
||||
"pwa_title": "Progressiv web-app (PWA)",
|
||||
"pwa_body": "Kapteins Daagbok kører som en progressiv webapp i din browser og kan installeres på din enhed - på samme måde som en native app, men uden en app store.",
|
||||
"storage_title": "Lokal lagring og synkronisering",
|
||||
"storage_body": "Dine data gemmes lokalt på din enhed (IndexedDB). Ændringer synkroniseres med serveren, når en internetforbindelse er aktiv. Du kan arbejde videre uden forbindelse; synkroniseringen finder sted senere.",
|
||||
"free_title": "Gratis og uden reklamer",
|
||||
"free_body": "Kapteins Daagbok er gratis og indeholder ingen reklamer.",
|
||||
"liability_title": "Ansvarsfraskrivelse",
|
||||
"liability_body": "Brug af appen sker på egen risiko. Vi påtager os intet ansvar for skader, der opstår som følge af brugen af appen - herunder forkerte eller ufuldstændige logbogsindførsler, tab af data eller tekniske fejl.",
|
||||
"warranty_title": "Ingen garanti",
|
||||
"warranty_body": "Der gives ingen garanti for tjenestens funktion, korrekthed eller tilgængelighed. Driften kan til enhver tid blive afbrudt, begrænset eller annulleret.",
|
||||
"copyright": "© 2026 KnorrLabs, Markus F.J. Busche",
|
||||
"accept": "Accepter og fortsæt",
|
||||
"close": "Luk",
|
||||
"button_title": "Noter og ansvarsfraskrivelse"
|
||||
},
|
||||
"feedback": {
|
||||
"button_title": "Send feedback",
|
||||
"title": "Feedback",
|
||||
"intro": "Del fejl, ideer eller generel feedback. Din besked vil blive sendt til projektteamet via en sikker meddelelseskanal.",
|
||||
"category_label": "Kategori",
|
||||
"category_general": "Generelt",
|
||||
"category_bug": "Rapporter fejl",
|
||||
"category_feature": "Anmodning om funktion",
|
||||
"category_translation": "Oversættelsesfejl",
|
||||
"contact_label": "E-mail (valgfrit)",
|
||||
"contact_placeholder": "deine@email.beispiel",
|
||||
"message_label": "Besked",
|
||||
"message_placeholder": "Beskriv din feedback...",
|
||||
"send": "Send",
|
||||
"sending": "Vil blive sendt...",
|
||||
"cancel": "Annuller",
|
||||
"success": "Tusind tak skal du have! Din feedback er blevet sendt.",
|
||||
"error_send": "Feedback kunne ikke sendes. Prøv venligst igen senere.",
|
||||
"error_invalid_email": "Indtast venligst en gyldig e-mailadresse.",
|
||||
"error_not_configured": "Feedback er ikke tilgængelig på denne server.",
|
||||
"error_rate_limited": "For mange tilbagemeldinger på kort tid. Vent venligst et par minutter.",
|
||||
"error_spam": "Denne besked kunne ikke sendes. Vær venlig at omformulere den."
|
||||
},
|
||||
"demo": {
|
||||
"logbook_title": "Demo-logbog Østersøen",
|
||||
"badge": "Demo",
|
||||
"public_banner": "Skrivebeskyttet demo-visning",
|
||||
"cta_register": "Opret konto",
|
||||
"back_to_login": "Til registreringen"
|
||||
},
|
||||
"invitation": {
|
||||
"error_invalid_key": "Invitationslinket er kryptografisk ugyldigt (nøglen er forkert).",
|
||||
"error_missing_key": "Invitationslinket indeholder ikke en dekrypteringsnøgle (#key=...). Brug venligst det fulde link fra ejeren.",
|
||||
"error_expired": "Denne invitation er udløbet (gyldig i 48 timer).",
|
||||
"error_invalid_token": "Invitationstokenet er ugyldigt.",
|
||||
"error_load_failed": "Invitationsoplysningerne kunne ikke indlæses.",
|
||||
"error_incomplete_session": "Session ufuldstændig - log venligst ind igen (bruger-ID mangler).",
|
||||
"error_accept_failed": "Tiltrædelse mislykkedes.",
|
||||
"error_login_failed": "Passkey Login mislykkedes.",
|
||||
"error_username_missing": "Brugernavnet kunne ikke bestemmes - log venligst ind igen.",
|
||||
"error_register_failed": "Registrering mislykkedes.",
|
||||
"loading_joining": "At slutte sig til...",
|
||||
"loading_checking": "Invitation vil blive tjekket...",
|
||||
"loading_unlocking": "Logbogen er låst op og synkroniseret...",
|
||||
"loading_retrieving_key": "Download krypteringsnøgle...",
|
||||
"error_title": "Fejl i invitation",
|
||||
"back_to_start": "Tilbage til start",
|
||||
"title": "Invitation til logbog",
|
||||
"invited_by": "Invitation fra",
|
||||
"vessel_logbook": "Skib / Logbog",
|
||||
"signed_in_preparing": "Registreret som {{username}}. Tilslutning er ved at blive forberedt...",
|
||||
"join_again": "Deltag igen",
|
||||
"login_or_register_hint": "Log ind eller opret en konto for at deltage i logbogen.",
|
||||
"or_sign_up": "ELLER REGISTRER DIG IGEN",
|
||||
"register_crew_account": "Opret en ny crew-konto",
|
||||
"username_label": "Brugernavn",
|
||||
"create_passkey": "Opret Passkey.",
|
||||
"switch_language_en": "Engelsk",
|
||||
"switch_language_de": "Tysk"
|
||||
},
|
||||
"stats": {
|
||||
"title": "Statistik",
|
||||
"subtitle": "Overblik over ruter, forbrug og kørselstype",
|
||||
"scope_label": "Evalueringsområde",
|
||||
"scope_logbook": "Denne logbog",
|
||||
"scope_account": "Alle logbøger",
|
||||
"loading": "Statistikkerne er beregnet...",
|
||||
"no_data": "Ingen rejsedage tilgængelige endnu.",
|
||||
"total_distance": "Samlet afstand",
|
||||
"travel_days": "Rejsedage",
|
||||
"sail_distance": "Under sejl",
|
||||
"motor_distance": "Kørsel med maskine",
|
||||
"motor_hours_total": "Samlet antal maskintimer",
|
||||
"daily_motor_hours": "Maskintimer pr. rejsedag",
|
||||
"avg_motor_hours": "Ø maskintimer pr. rejsedag",
|
||||
"unknown_propulsion": "Ukendt",
|
||||
"fuel_total": "Brændstof i alt",
|
||||
"water_total": "Vand i alt",
|
||||
"daily_etmal": "Daglige tider",
|
||||
"daily_consumption": "Dagligt forbrug",
|
||||
"route_overview": "Rute",
|
||||
"route_map_title": "Oversigt over ruter",
|
||||
"propulsion_title": "Sejl vs. maskine",
|
||||
"propulsion_hint": "Opdelingen er baseret på logbogshændelser pr. rejsedag, ikke på GPS-segmenter.",
|
||||
"avg_distance": "Ø pr. rejsedag",
|
||||
"avg_fuel": "Ø Brændstof",
|
||||
"avg_water": "Ø Vand",
|
||||
"fuel_per_nm": "Brændstof pr. sm",
|
||||
"fuel_per_motor_hour": "Brændstof pr. maskintime",
|
||||
"daily_fuel_per_motor_hour": "Brændstofforbrug pr. maskintime pr. rejsedag",
|
||||
"fuel_legend": "Brændstof",
|
||||
"water_legend": "Vand",
|
||||
"unit_nm": "sm",
|
||||
"unit_h": "h",
|
||||
"unit_l": "L",
|
||||
"day_label": "Dag {{day}}",
|
||||
"account_logbooks": "Et overblik over logbøger",
|
||||
"col_logbook": "Logbog",
|
||||
"event_series_title": "Hændelsesforløb",
|
||||
"event_series_hint": "Kronologiske værdier fra hændelsesloggen.",
|
||||
"event_series_pressure": "Lufttryk",
|
||||
"event_series_wind": "Vind",
|
||||
"event_series_motor": "Motor",
|
||||
"event_series_empty": "Ingen indtastninger endnu."
|
||||
},
|
||||
"tour": {
|
||||
"skip": "Spring turen over",
|
||||
"back": "Tilbage",
|
||||
"next": "Yderligere",
|
||||
"finish": "Klar",
|
||||
"progress": "Trin {{current}} fra {{total}}.",
|
||||
"steps": {
|
||||
"welcome": {
|
||||
"title": "Velkommen om bord!",
|
||||
"body": "Vi har lavet en demo-logbog med tre dages rejse i Kielerfjorden til dig. Du kan til enhver tid slette prøveposterne, hvis du vil starte din egen logbog. Denne korte rundvisning viser dig de vigtigste funktioner."
|
||||
},
|
||||
"welcome_public": {
|
||||
"title": "Velkommen om bord!",
|
||||
"body": "Udforsk vores demo-logbog med tre dages rejse i Kielerfjorden - uden en konto. Denne korte tur viser dig skibsdata, besætning og logbogsposter."
|
||||
},
|
||||
"nav_logs": {
|
||||
"title": "Indlæg i logbogen",
|
||||
"body": "Det er her, du styrer dine rejsedage - afgang, destination, vejr, brændstofniveau og GPS-spor."
|
||||
},
|
||||
"entry_list": {
|
||||
"title": "Dine rejsedage",
|
||||
"body": "Hvert kort repræsenterer en rejsedag. Tryk på en post for at se eller redigere detaljer."
|
||||
},
|
||||
"entry_open": {
|
||||
"title": "Åben rejsedag",
|
||||
"body": "Sådan ser et udfyldt logbogsnotat ud - med begivenheder, tankniveauer og meget mere."
|
||||
},
|
||||
"entry_track": {
|
||||
"title": "GPS-spor",
|
||||
"body": "Upload GPX-filer, eller se allerede gemte ruter på kortet - inklusive afstand og hastighed."
|
||||
},
|
||||
"nav_vessel": {
|
||||
"title": "Skibsdata",
|
||||
"body": "Indtast navn, dimensioner og tekniske data for din yacht - udfyld én gang, tilgængelig for alle rejsedage."
|
||||
},
|
||||
"nav_crew": {
|
||||
"title": "Besætningsliste",
|
||||
"body": "Administrer besætningsmedlemmer og tildel dem rejsedage senere."
|
||||
},
|
||||
"nav_stats": {
|
||||
"title": "Statistik-dashboard",
|
||||
"body": "Her kan du se kørselsafstande, brændstofforbrug, rutekort og kørselsandele - automatisk beregnet ud fra dine logbogsnotater."
|
||||
},
|
||||
"nav_feedback": {
|
||||
"title": "Send feedback",
|
||||
"body": "Du kan bruge denne formular til at sende fejl, ideer eller generel feedback direkte til projektteamet - også efter rundvisningen, når som helst ved hjælp af ikonet øverst til højre."
|
||||
},
|
||||
"nav_profile": {
|
||||
"title": "Din brugerprofil",
|
||||
"body": "Du kan få adgang til din personlige profil via skipperknappen øverst - uanset den aktuelle logbog."
|
||||
},
|
||||
"profile_preferences": {
|
||||
"title": "Regnskab og præsentation",
|
||||
"body": "Her kan du administrere din kontoidentitet, tema og lys/mørk tilstand. Du kan til enhver tid genstarte app-turen. Passkeys og sikkerhedsindstillinger findes længere nede i profilen."
|
||||
},
|
||||
"finish": {
|
||||
"title": "Okay!",
|
||||
"body": "Du vil blive ført direkte til statistikdashboardet. Du kan til enhver tid genstarte turen i din brugerprofil. Hav en god tur!"
|
||||
}
|
||||
}
|
||||
},
|
||||
"seo": {
|
||||
"title": "Kapteins Daagbok - Gratis digital yachtlogbog (reklamefri)",
|
||||
"description": "Gratis, reklamefri digital yachtlogbog med end-to-end-kryptering og Passkey-login. Dokumenter sikkert rejsedage, GPS-spor, besætnings- og skibsdata - også offline som PWA.",
|
||||
"keywords": "Yachtlogbog, skibslogbog, logbog om bord, sejlads, Passkey, E2E-kryptering, GPS-spor, maritim logbog, gratis, reklamefri, gratis, uden reklame",
|
||||
"ogImageAlt": "Kapteins Daagbok Logo"
|
||||
}
|
||||
}
|
||||
}
|
||||
+259
-39
@@ -6,6 +6,13 @@
|
||||
"beta": "Beta",
|
||||
"beta_hint": "Beta-Version — Funktionen können sich noch ändern"
|
||||
},
|
||||
"languages": {
|
||||
"de": "Deutsch",
|
||||
"en": "English",
|
||||
"da": "Dansk",
|
||||
"sv": "Svenska",
|
||||
"nb": "Norsk"
|
||||
},
|
||||
"common": {
|
||||
"unsaved_changes_title": "Ungespeicherte Änderungen",
|
||||
"unsaved_changes_message": "Du hast ungespeicherte Änderungen. Möchtest du die Seite wirklich verlassen? Deine Änderungen gehen verloren.",
|
||||
@@ -23,7 +30,7 @@
|
||||
},
|
||||
"auth": {
|
||||
"welcome": "Willkommen bei Kapteins Daagbok",
|
||||
"tagline": "Sicheres, E2E-verschlüsseltes maritimes Logbuch.",
|
||||
"tagline": "Dein sicheres, E2E-verschlüsseltes maritimes Logbuch.",
|
||||
"register": "Mit Passkey registrieren",
|
||||
"login": "Mit Passkey anmelden",
|
||||
"login_as": "Anmelden als {{name}}",
|
||||
@@ -61,7 +68,12 @@
|
||||
"enter_pin_placeholder": "Gib deine PIN ein...",
|
||||
"decrypt_with_pin": "Entschlüsseln",
|
||||
"use_recovery_instead": "Stattdessen Wiederherstellungsschlüssel verwenden",
|
||||
"error_incorrect_pin": "Falsche PIN. Entschlüsselung fehlgeschlagen."
|
||||
"error_incorrect_pin": "Falsche PIN. Entschlüsselung fehlgeschlagen.",
|
||||
"error_invalid_host": "Passkeys funktionieren nicht über 127.0.0.1. Bitte die App über localhost öffnen.",
|
||||
"use_localhost_link": "Zu localhost wechseln",
|
||||
"error_passkey_cancelled": "Passkey-Anmeldung abgebrochen oder abgelaufen. Bitte erneut versuchen.",
|
||||
"error_invalid_rp_id": "Passkey-Domain passt nicht (RP ID). Lokal nur http://localhost:5173 mit RP_ID=localhost in .env verwenden.",
|
||||
"error_session_incomplete": "Anmeldung unvollständig. Bitte erneut mit Passkey anmelden."
|
||||
},
|
||||
"pwa": {
|
||||
"title": "App installieren",
|
||||
@@ -84,6 +96,7 @@
|
||||
},
|
||||
"sync": {
|
||||
"status_synced": "Synchronisiert",
|
||||
"status_syncing": "Synchronisiere…",
|
||||
"status_offline": "Offline-Cache",
|
||||
"status_unsynced": "Unsynchronisierte Änderungen"
|
||||
},
|
||||
@@ -116,7 +129,13 @@
|
||||
"no_sails": "Keine Segel hinterlegt.",
|
||||
"photo_add": "Foto hinzufügen",
|
||||
"photo_change": "Foto ändern",
|
||||
"photo_delete": "Foto löschen"
|
||||
"photo_delete": "Foto löschen",
|
||||
"tanks_section": "Tanks (Fassungsvermögen)",
|
||||
"tanks_help": "Optional in Liter — ermöglicht Slider im Journal bei bekannten Tankgrößen.",
|
||||
"freshwater_capacity_l": "Trinkwasser (Liter)",
|
||||
"fuel_capacity_l": "Treibstoff (Liter)",
|
||||
"greywater_capacity_l": "Grauwasser (Liter)",
|
||||
"invalid_tank_liters": "Ungültiger Zahlenwert — bitte Liter als Zahl eingeben (z. B. 200)."
|
||||
},
|
||||
"logs": {
|
||||
"title": "Logbuch-Journal",
|
||||
@@ -137,6 +156,10 @@
|
||||
"route": "Reise von/nach",
|
||||
"freshwater": "Frischwasser (Liter)",
|
||||
"fuel": "Treibstoff / Fuel (Liter)",
|
||||
"greywater": "Grauwasser (Liter)",
|
||||
"greywater_level": "Füllstand",
|
||||
"tank_slider_of_max": "{{current}} / {{max}} L",
|
||||
"tank_capacity_tooltip": "Wenn in den Schiffsdaten die Tank-Fassungsvermögen (Liter) hinterlegt sind, kannst du Füllstände hier per Slider eingeben.",
|
||||
"morning": "Stand morgens",
|
||||
"refilled": "Nachgefüllt",
|
||||
"evening": "Stand abends",
|
||||
@@ -179,10 +202,98 @@
|
||||
"saving": "Wird gespeichert...",
|
||||
"saved": "Logbuchseite erfolgreich gespeichert!",
|
||||
"loading": "Journal wird geladen...",
|
||||
"view_mode_label": "Ansicht",
|
||||
"view_list": "Liste",
|
||||
"live_mode": "Live",
|
||||
"live_title": "Live-Journal",
|
||||
"live_loading": "Live-Journal wird geladen...",
|
||||
"live_retry": "Erneut versuchen",
|
||||
"live_load_error": "Live-Journal konnte nicht geladen werden.",
|
||||
"live_action_error": "Eintrag konnte nicht gespeichert werden.",
|
||||
"live_open_editor": "Vollständiger Editor",
|
||||
"live_actions_label": "Schnellaktionen",
|
||||
"live_stream_label": "Ereignisprotokoll",
|
||||
"live_stream_title": "Journal",
|
||||
"live_no_events": "Noch keine Einträge — tippe auf eine Aktion.",
|
||||
"live_motor_start": "Motor Start",
|
||||
"live_motor_stop": "Motor Stop",
|
||||
"live_cast_off": "Ablegen",
|
||||
"live_moor": "Anlegen",
|
||||
"live_sails_btn": "Segel",
|
||||
"live_sails_pick": "Segel auswählen",
|
||||
"live_sails_pick_hint": "Mehrere Segel antippen (erneut antippen zum Abwählen), dann Eintragen.",
|
||||
"live_sails_selected": "Auswahl: {{sails}}",
|
||||
"live_sails_confirm": "Eintragen",
|
||||
"live_sails_confirm_count": "Eintragen ({{count}})",
|
||||
"live_sails": "Segel: {{sails}}",
|
||||
"live_fix": "Fix",
|
||||
"live_fix_coords": "Fix {{lat}}, {{lng}}",
|
||||
"live_fix_manual_hint": "GPS nicht verfügbar. Breiten- und Längengrad manuell eingeben oder erneut per GPS-Knopf versuchen.",
|
||||
"live_fix_gps_loading": "GPS-Position wird ermittelt…",
|
||||
"live_fix_invalid": "Bitte gültige Koordinaten eingeben (Breite −90…90, Länge −180…180).",
|
||||
"live_fix_lat_placeholder": "Breite (Lat)",
|
||||
"live_fix_lng_placeholder": "Länge (Lng)",
|
||||
"live_photo_btn": "Foto (Kamera)",
|
||||
"live_photo_capture_btn": "Aufnehmen",
|
||||
"live_photo_save_btn": "Speichern",
|
||||
"live_photo_retake_btn": "Neu aufnehmen",
|
||||
"live_photo_capture_failed": "Aufnahme fehlgeschlagen. Bitte erneut versuchen.",
|
||||
"live_photo_open_camera_btn": "Kamera öffnen",
|
||||
"live_photo_native_hint": "Foto mit der Gerätekamera aufnehmen und anschließend hier speichern.",
|
||||
"live_photo_camera_starting": "Kamera wird gestartet…",
|
||||
"live_photo_camera_denied": "Kamerazugriff verweigert oder nicht verfügbar.",
|
||||
"live_photo_camera_unavailable": "Kamera wird von diesem Browser nicht unterstützt.",
|
||||
"live_photo_error": "Foto konnte nicht gespeichert werden.",
|
||||
"live_photo_entry": "Foto: {{caption}}",
|
||||
"live_photo_entry_plain": "Foto aufgenommen",
|
||||
"live_undo_photo_hint": "Foto gespeichert",
|
||||
"live_comment_btn": "Kommentar",
|
||||
"live_comment_placeholder": "Freitext eingeben…",
|
||||
"live_comment_confirm": "Eintragen",
|
||||
"live_gps_error": "GPS-Position konnte nicht ermittelt werden.",
|
||||
"live_event_generic": "Ereignis",
|
||||
"live_weather_btn": "Wetter",
|
||||
"live_weather_owm_btn": "OpenWeatherMap Wetter abrufen",
|
||||
"live_weather_owm_loading": "Wetter wird geladen…",
|
||||
"live_weather_fix_required": "Für Wetter von OpenWeatherMap zuerst einen GPS-Fix eintragen (Schaltfläche „Fix“). Die Position darf höchstens 6 Stunden alt sein.",
|
||||
"live_weather_fix_stale": "Der letzte GPS-Fix ist älter als 6 Stunden. Bitte erneut einen Fix loggen, bevor du Wetter abrufst.",
|
||||
"live_wind_btn": "Wind",
|
||||
"live_temp_btn": "T °C",
|
||||
"live_pressure_btn": "Luftdruck",
|
||||
"live_precip_btn": "Niederschlag",
|
||||
"live_sea_state_btn": "Seegang",
|
||||
"live_course_btn": "Kurs",
|
||||
"live_fuel_btn": "Diesel",
|
||||
"live_water_btn": "Wasser",
|
||||
"live_wind_entry": "Wind {{value}}",
|
||||
"live_temp_entry": "Temperatur {{temp}} °C",
|
||||
"live_pressure_entry": "Luftdruck {{value}} hPa",
|
||||
"live_precip_entry": "Niederschlag {{value}}",
|
||||
"live_sea_state_entry": "Seegang {{value}}",
|
||||
"live_course_entry": "Kurs {{course}}",
|
||||
"live_fuel_entry": "Diesel +{{liters}} L",
|
||||
"live_water_entry": "Wasser +{{liters}} L",
|
||||
"live_auto_position": "Auto-Position",
|
||||
"live_undo_hint": "Eintrag gespeichert",
|
||||
"live_undo_btn": "Rückgängig",
|
||||
"live_pressure_placeholder": "z. B. 1013",
|
||||
"live_temp_placeholder": "z. B. 18",
|
||||
"live_precip_placeholder": "z. B. leichter Regen",
|
||||
"live_sea_state_placeholder": "z. B. 3",
|
||||
"live_course_placeholder": "z. B. 245",
|
||||
"live_fuel_placeholder": "Nachgefüllte Liter",
|
||||
"live_water_placeholder": "Nachgefüllte Liter",
|
||||
"live_sog_btn": "SOG",
|
||||
"live_stw_btn": "STW",
|
||||
"live_sog_entry": "SOG {{speed}} kn",
|
||||
"live_stw_entry": "STW {{speed}} kn",
|
||||
"live_sog_placeholder": "z. B. 5,2",
|
||||
"live_stw_placeholder": "z. B. 4,8",
|
||||
"live_sog_hint": "Fahrt über Grund (kn) — GPS-Wert wird vorgefüllt, wenn verfügbar.",
|
||||
"delete_entry": "Tag löschen",
|
||||
"delete_confirm": "Bist du sicher, dass du diesen Reisetag unwiderruflich löschen möchtest?",
|
||||
"carry_over_tanks_title": "Daten vom Vortag übernehmen?",
|
||||
"carry_over_tanks_confirm": "Start-Hafen, Frischwasser- und Kraftstoff-Morgenstände vom letzten Reisetag übernehmen?\n\nStart-Hafen: {{departure}}\nFrischwasser: {{fw}} L\nKraftstoff: {{fuel}} L",
|
||||
"carry_over_tanks_confirm": "Start-Hafen, Frischwasser-, Kraftstoff- und Grauwasser-Startstände vom letzten Reisetag übernehmen?\n\nStart-Hafen: {{departure}}\nFrischwasser: {{fw}} L\nKraftstoff: {{fuel}} L\nGrauwasser: {{greywater}} L",
|
||||
"carry_over_tanks_yes": "Übernehmen",
|
||||
"carry_over_tanks_no": "Mit 0 starten",
|
||||
"event_title": "Chronologisches Ereignisprotokoll",
|
||||
@@ -190,6 +301,23 @@
|
||||
"event_time": "Uhrzeit",
|
||||
"event_mgk": "MgK Kurs",
|
||||
"event_rwk": "RwK Kurs",
|
||||
"event_course_section": "Kurs",
|
||||
"course_dial_hint": "Am Ring drehen oder Grad eingeben",
|
||||
"course_dial_step_label": "Schrittweite",
|
||||
"course_step_fine": "1°",
|
||||
"course_step_medium": "5°",
|
||||
"course_step_coarse": "10°",
|
||||
"course_tab_mgk": "MgK",
|
||||
"course_tab_rwk": "rwK",
|
||||
"course_invalid": "Ungültiger Kurs (0–360)",
|
||||
"course_placeholder_degrees": "z. B. 180",
|
||||
"course_placeholder_cardinal": "z. B. NW",
|
||||
"compass_n": "N",
|
||||
"compass_e": "O",
|
||||
"compass_s": "S",
|
||||
"compass_w": "W",
|
||||
"wind_mode_cardinal": "Kardinal",
|
||||
"wind_mode_degrees": "Als Grad",
|
||||
"event_wind_direction": "Wind-Richtung",
|
||||
"event_wind_strength": "Windstärke",
|
||||
"event_sea_state": "Seegang",
|
||||
@@ -205,6 +333,8 @@
|
||||
"event_heel": "Krängung (°)",
|
||||
"event_sails": "Segelführung / Motor",
|
||||
"motor_propulsion": "Maschinenfahrt",
|
||||
"sails_picker_show_more": "Alle Segel anzeigen",
|
||||
"sails_picker_show_less": "Weniger anzeigen",
|
||||
"motor_hours": "Maschinenstunden (gesamt)",
|
||||
"fuel_per_motor_hour": "Verbrauch pro Maschinenstunde",
|
||||
"event_distance": "Distanz (sm)",
|
||||
@@ -236,6 +366,56 @@
|
||||
"track_map_end": "Ziel",
|
||||
"track_map_speed_slow": "langsam",
|
||||
"track_map_speed_fast": "schnell",
|
||||
"nmea_import_title": "NMEA-Protokoll importieren",
|
||||
"nmea_import_intro": "Lade eine .nmea-Datei vom Bord-Logger. Die App schlägt Journal-Einträge vor — du entscheidest, was übernommen wird.",
|
||||
"nmea_import_btn": "NMEA importieren",
|
||||
"nmea_file_label": "NMEA-Datei",
|
||||
"nmea_stats": "{{lines}} Sätze erkannt · Typen: {{types}}",
|
||||
"nmea_warn_no_position": "Keine Positions-Sätze gefunden — Track und GPS-Felder können leer bleiben.",
|
||||
"nmea_warn_duplicate_file": "Diese NMEA-Datei wurde bereits importiert. Ein erneuter Import derselben Datei fügt doppelte Journal-Einträge hinzu.",
|
||||
"nmea_mode_label": "Journal-Einträge erzeugen",
|
||||
"nmea_mode_interval": "Nach Zeitintervall",
|
||||
"nmea_mode_change": "Bei signifikanter Änderung",
|
||||
"nmea_mode_both": "Beides (zusammenführen)",
|
||||
"nmea_interval_label": "Intervall (Minuten)",
|
||||
"nmea_import_track": "GPS-Track aus NMEA übernehmen",
|
||||
"nmea_preview": "Vorschau",
|
||||
"nmea_preview_hint": "{{count}} vorgeschlagene Journal-Einträge",
|
||||
"nmea_select_all": "Alle auswählen",
|
||||
"nmea_select_none": "Keine auswählen",
|
||||
"nmea_source_interval": "Intervall",
|
||||
"nmea_source_change": "Ereignis",
|
||||
"nmea_apply": "In Journal übernehmen",
|
||||
"nmea_back": "Zurück",
|
||||
"nmea_cancel": "Abbrechen",
|
||||
"nmea_archive_question": "Rohprotokoll lokal archivieren? (Nur auf diesem Gerät, nicht synchronisiert.)",
|
||||
"nmea_archive_keep": "Archivieren",
|
||||
"nmea_archive_discard": "Verwerfen",
|
||||
"nmea_archive_stored": "NMEA archiviert: {{name}}",
|
||||
"nmea_archive_delete_confirm": "Archiviertes NMEA-Protokoll von diesem Gerät löschen?",
|
||||
"nmea_error_no_samples": "Keine verwertbaren NMEA-Sätze in der Datei.",
|
||||
"nmea_error_parse": "NMEA-Datei konnte nicht gelesen werden.",
|
||||
"nmea_error_read": "Datei konnte nicht gelesen werden.",
|
||||
"nmea_error_no_file": "Bitte zuerst eine NMEA-Datei wählen.",
|
||||
"nmea_error_no_selection": "Bitte mindestens einen Journal-Eintrag auswählen.",
|
||||
"nmea_remark_interval": "NMEA Intervall",
|
||||
"nmea_remark_uncertain": "unsicher",
|
||||
"nmea_remark_depth": "Tiefe {{depth}} m",
|
||||
"nmea_change_course": "Kursänderung {{from}}° → {{to}}°",
|
||||
"nmea_change_wind": "Wind {{from}}° → {{to}}°",
|
||||
"nmea_change_wind_speed": "Wind {{from}} → {{to}} kn",
|
||||
"nmea_change_pressure": "Luftdruck {{from}} → {{to}} hPa",
|
||||
"nmea_change_depth": "Tiefe {{from}} → {{to}} m",
|
||||
"nmea_change_engine_start": "Motor an ({{rpm}} U/min)",
|
||||
"nmea_change_engine_stop": "Motor aus",
|
||||
"nmea_change_autopilot_on": "Autopilot ein",
|
||||
"nmea_change_autopilot_off": "Autopilot aus",
|
||||
"nmea_change_gps_lost": "GPS-Fix verloren",
|
||||
"nmea_change_gps_regained": "GPS-Fix wiederhergestellt",
|
||||
"nmea_change_water_temp": "Wassertemp. {{from}} → {{to}} °C",
|
||||
"nmea_change_departure": "Abfahrt / Fahrtbeginn",
|
||||
"nmea_change_anchor": "Ankern / Stop",
|
||||
"nmea_change_speed": "Geschw. {{from}} → {{to}} kn",
|
||||
"track_map_error": "Karte konnte nicht geladen werden.",
|
||||
"exporting": "Exportiere...",
|
||||
"share_unsupported": "Teilen wird auf diesem Gerät nicht unterstützt. Datei wurde stattdessen heruntergeladen.",
|
||||
@@ -274,7 +454,23 @@
|
||||
"edit_title": "Logbuch umbenennen",
|
||||
"edit_placeholder": "Neuer Name des Logbuchs",
|
||||
"edit_success": "Logbuch erfolgreich umbenannt",
|
||||
"edit_btn": "Umbenennen"
|
||||
"edit_btn": "Umbenennen",
|
||||
"filter_label": "Logbücher filtern",
|
||||
"filter_placeholder": "Name, Jahr oder Datum …",
|
||||
"filter_clear": "Filter zurücksetzen",
|
||||
"filter_results": "{{count}} Treffer",
|
||||
"filter_no_results": "Keine Logbücher passen zu deiner Suche. Probiere einen anderen Namen oder ein anderes Jahr.",
|
||||
"sort_label": "Sortieren",
|
||||
"sort_by_label": "Sortieren nach",
|
||||
"sort_by_name": "Name",
|
||||
"sort_by_date": "Datum",
|
||||
"sort_dir_label": "Reihenfolge",
|
||||
"sort_asc": "Aufsteigend",
|
||||
"sort_desc": "Absteigend",
|
||||
"sort_name_asc": "Name A bis Z",
|
||||
"sort_name_desc": "Name Z bis A",
|
||||
"sort_date_asc": "Älteste zuerst",
|
||||
"sort_date_desc": "Neueste zuerst"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Benutzerprofil",
|
||||
@@ -363,7 +559,35 @@
|
||||
"stats_subtitle": "Über alle deine Logbücher auf diesem Gerät",
|
||||
"stats_logbooks": "Logbücher",
|
||||
"stats_account_since": "Konto seit",
|
||||
"stats_shared_logbooks": "Geteilte Logbücher"
|
||||
"stats_shared_logbooks": "Geteilte Logbücher",
|
||||
"appearance_title": "App & Darstellung",
|
||||
"appearance_desc": "Design und Farbschema gelten für die gesamte App auf diesem Gerät.",
|
||||
"theme_label": "Design-Stil der App",
|
||||
"theme_auto": "Automatisch (OS-Erkennung)",
|
||||
"theme_ocean": "Ocean (Glassmorphismus)",
|
||||
"theme_material": "Material (Android)",
|
||||
"theme_cupertino": "Cupertino (iOS)",
|
||||
"color_scheme_label": "Hell- oder Dunkelmodus",
|
||||
"color_scheme_auto": "Automatisch (System)",
|
||||
"color_scheme_light": "Hell",
|
||||
"color_scheme_dark": "Dunkel",
|
||||
"integrations_title": "Integrationen",
|
||||
"owm_key": "OpenWeatherMap API-Schlüssel",
|
||||
"owm_help": "Optional: eigener OpenWeatherMap-API-Schlüssel. Ohne Eintrag wird der serverseitige Schlüssel aus der Betreiber-Konfiguration verwendet.",
|
||||
"prefs_save": "Speichern",
|
||||
"prefs_saving": "Wird gespeichert…",
|
||||
"prefs_saved": "Gespeichert",
|
||||
"tour_title": "App-Tour",
|
||||
"tour_desc": "Lass dich erneut durch die wichtigsten Bereiche der App führen.",
|
||||
"tour_restart": "Tour erneut starten",
|
||||
"push_title": "Push-Benachrichtigungen",
|
||||
"push_desc": "Als Logbuch-Eigner wirst du benachrichtigt, wenn eingeladene Crewmitglieder Änderungen synchronisieren. Es werden keine Inhalte im Klartext übermittelt.",
|
||||
"push_enable": "Bei Crew-Änderungen benachrichtigen",
|
||||
"push_active": "Push-Benachrichtigungen sind auf diesem Gerät aktiv.",
|
||||
"push_unsupported": "Push-Benachrichtigungen werden in diesem Browser nicht unterstützt.",
|
||||
"push_denied_hint": "Benachrichtigungen sind blockiert. Erlaube sie in den Browser- oder Geräteeinstellungen.",
|
||||
"push_ios_install_hint": "Auf dem iPhone/iPad: App zum Home-Bildschirm hinzufügen (iOS 16.4+), um Push zu nutzen.",
|
||||
"push_error": "Push-Benachrichtigungen konnten nicht aktiviert werden."
|
||||
},
|
||||
"crew": {
|
||||
"title": "Skipper- & Crew-Profile",
|
||||
@@ -400,36 +624,22 @@
|
||||
"loading": "Kalibrierungstabelle wird geladen..."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Systemeinstellungen",
|
||||
"subtitle": "Konfiguriere externe Integrationen und Anmeldedaten.",
|
||||
"owm_title": "Wetter-Integration",
|
||||
"owm_key": "OpenWeatherMap API-Schlüssel",
|
||||
"save": "Konfiguration speichern",
|
||||
"saving": "Wird gespeichert...",
|
||||
"saved": "Einstellungen erfolgreich gespeichert!",
|
||||
"key_help": "Optional: eigener OpenWeatherMap-API-Schlüssel. Ohne Eintrag wird der serverseitige Schlüssel aus der Betreiber-Konfiguration verwendet.",
|
||||
"no_key": "Kein OpenWeatherMap-API-Schlüssel verfügbar. Hinterlege einen eigenen Schlüssel in den Einstellungen oder kontaktiere den Betreiber.",
|
||||
"title": "Logbuch-Einstellungen",
|
||||
"subtitle": "Teilen, Backup und Zusammenarbeit für dieses Logbuch.",
|
||||
"select_logbook_hint": "Wähle ein Logbuch aus, um dessen Einstellungen zu bearbeiten.",
|
||||
"no_key": "Kein OpenWeatherMap-API-Schlüssel verfügbar. Hinterlege einen eigenen Schlüssel im Benutzerprofil oder kontaktiere den Betreiber.",
|
||||
"weather_success": "Wetterdaten erfolgreich abgerufen!",
|
||||
"weather_error": "Wetterdatenabruf fehlgeschlagen. Überprüfe den API-Schlüssel und die Verbindung.",
|
||||
"weather_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.",
|
||||
"theme_title": "Design-Anpassung",
|
||||
"theme_label": "Design-Stil der App",
|
||||
"theme_auto": "Automatisch (OS-Erkennung)",
|
||||
"theme_ocean": "Ocean (Glassmorphismus)",
|
||||
"theme_material": "Material (Android)",
|
||||
"theme_cupertino": "Cupertino (iOS)",
|
||||
"color_scheme_title": "Erscheinungsbild",
|
||||
"color_scheme_label": "Hell- oder Dunkelmodus (Standard: Systemeinstellung)",
|
||||
"color_scheme_auto": "Automatisch (System)",
|
||||
"color_scheme_light": "Hell",
|
||||
"color_scheme_dark": "Dunkel",
|
||||
"share_title": "Logbuch teilen (Schreibgeschützt)",
|
||||
"share_desc": "Aktiviere diese Option, um einen öffentlichen, schreibgeschützten Link zu erstellen. Jeder mit dem Link kann deine Reisen, Yacht-Profile und Besatzung ansehen. Die Verschlüsselungsschlüssel werden niemals an den Server übertragen (sie bleiben im Hash-Teil der URL).",
|
||||
"share_privacy_warning": "Empfehlung: Teile diesen Link nur privat (z. B. per E-Mail oder Messenger), nicht in sozialen Medien.",
|
||||
"share_enable": "Öffentlichen Link aktivieren",
|
||||
"share_copied": "Link kopiert!",
|
||||
"share_copy_btn": "Link kopieren",
|
||||
"link_qr_hint": "QR-Code zum Scannen mit dem Smartphone",
|
||||
"link_qr_alt": "QR-Code für den Link",
|
||||
"danger_zone_title": "Gefahrenzone",
|
||||
"danger_zone_desc": "Durch das Löschen deines Kontos werden alle deine Passkeys, Logbücher, Schiffsdaten, Crew-Profile, Reiseeinträge und E2E-Schlüssel unwiderruflich gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"delete_account_btn": "Konto unwiderruflich löschen",
|
||||
@@ -440,17 +650,12 @@
|
||||
"delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuche es erneut.",
|
||||
"delete_backup_hint": "Tipp: Erstelle vor dem Löschen Backups deiner Logbücher (.daagbok.json) in den Einstellungen jedes Logbuchs.",
|
||||
"deleting_account": "Konto wird gelöscht…",
|
||||
"tour_title": "App-Tour",
|
||||
"tour_desc": "Lass dich erneut durch die wichtigsten Bereiche der App führen.",
|
||||
"tour_restart": "Tour erneut starten",
|
||||
"push_title": "Push-Benachrichtigungen",
|
||||
"push_desc": "Als Logbuch-Eigner wirst du benachrichtigt, wenn eingeladene Crewmitglieder Änderungen synchronisieren. Es werden keine Inhalte im Klartext übermittelt.",
|
||||
"push_enable": "Bei Crew-Änderungen benachrichtigen",
|
||||
"push_active": "Push-Benachrichtigungen sind auf diesem Gerät aktiv.",
|
||||
"push_unsupported": "Push-Benachrichtigungen werden in diesem Browser nicht unterstützt.",
|
||||
"push_denied_hint": "Benachrichtigungen sind blockiert. Erlaube sie in den Browser- oder Geräteeinstellungen.",
|
||||
"push_ios_install_hint": "Auf dem iPhone/iPad: App zum Home-Bildschirm hinzufügen (iOS 16.4+), um Push zu nutzen.",
|
||||
"push_error": "Push-Benachrichtigungen konnten nicht aktiviert werden.",
|
||||
"invite_push_prompt_title": "Push-Benachrichtigungen aktivieren?",
|
||||
"invite_push_prompt_message": "Sobald eingeladene Crewmitglieder Änderungen synchronisieren, kannst du per Push informiert werden. Es werden keine Logbuch-Inhalte im Klartext gesendet.",
|
||||
"invite_push_prompt_ios_message": "Sobald Crewmitglieder Änderungen synchronisieren, kannst du per Push informiert werden. Auf dem iPhone/iPad: App zum Home-Bildschirm hinzufügen (iOS 16.4+), dann Push im Benutzerprofil aktivieren.",
|
||||
"invite_push_prompt_enable": "Jetzt aktivieren",
|
||||
"invite_push_prompt_later": "Später",
|
||||
"invite_push_prompt_success": "Push-Benachrichtigungen sind auf diesem Gerät aktiv.",
|
||||
"backup_title": "Backup & Wiederherstellung",
|
||||
"backup_desc": "Vollständiges verschlüsseltes Backup dieses Logbuchs (Einträge, Fotos, GPS-Tracks, Crew, Schiff). Mit Backup-Passphrase geschützt — für Restore auf diesem oder einem neuen Account.",
|
||||
"backup_export_title": "Backup erstellen",
|
||||
@@ -514,6 +719,7 @@
|
||||
"category_general": "Allgemein",
|
||||
"category_bug": "Fehler melden",
|
||||
"category_feature": "Feature-Wunsch",
|
||||
"category_translation": "Übersetzungsfehler",
|
||||
"contact_label": "E-Mail (optional)",
|
||||
"contact_placeholder": "deine@email.beispiel",
|
||||
"message_label": "Nachricht",
|
||||
@@ -602,7 +808,13 @@
|
||||
"unit_l": "L",
|
||||
"day_label": "Tag {{day}}",
|
||||
"account_logbooks": "Logbücher im Überblick",
|
||||
"col_logbook": "Logbuch"
|
||||
"col_logbook": "Logbuch",
|
||||
"event_series_title": "Ereignis-Verläufe",
|
||||
"event_series_hint": "Chronologische Werte aus dem Ereignisprotokoll.",
|
||||
"event_series_pressure": "Luftdruck",
|
||||
"event_series_wind": "Wind",
|
||||
"event_series_motor": "Motor",
|
||||
"event_series_empty": "Keine Einträge vorhanden."
|
||||
},
|
||||
"tour": {
|
||||
"skip": "Tour überspringen",
|
||||
@@ -651,9 +863,17 @@
|
||||
"title": "Feedback senden",
|
||||
"body": "Über dieses Formular kannst du Fehler, Ideen oder allgemeines Feedback direkt an das Projektteam schicken – auch nach der Tour jederzeit über das Symbol oben rechts."
|
||||
},
|
||||
"nav_profile": {
|
||||
"title": "Dein Benutzerprofil",
|
||||
"body": "Über den Skipper-Button oben erreichst du dein persönliches Profil – unabhängig vom aktuellen Logbuch."
|
||||
},
|
||||
"profile_preferences": {
|
||||
"title": "Konto & Darstellung",
|
||||
"body": "Hier verwaltest du deine Konto-Identität, Theme und Hell/Dunkel-Modus. Die App-Tour kannst du jederzeit erneut starten. Passkeys und Sicherheitseinstellungen findest du weiter unten im Profil."
|
||||
},
|
||||
"finish": {
|
||||
"title": "Alles klar!",
|
||||
"body": "Du landest gleich im Statistik-Dashboard. Die Tour kannst du jederzeit unter Einstellungen erneut starten. Gute Fahrt!"
|
||||
"body": "Du landest gleich im Statistik-Dashboard. Die Tour kannst du jederzeit im Benutzerprofil erneut starten. Gute Fahrt!"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+258
-38
@@ -6,6 +6,13 @@
|
||||
"beta": "Beta",
|
||||
"beta_hint": "Beta release — features may still change"
|
||||
},
|
||||
"languages": {
|
||||
"de": "Deutsch",
|
||||
"en": "English",
|
||||
"da": "Dansk",
|
||||
"sv": "Svenska",
|
||||
"nb": "Norsk"
|
||||
},
|
||||
"common": {
|
||||
"unsaved_changes_title": "Unsaved changes",
|
||||
"unsaved_changes_message": "You have unsaved changes. Leave this page anyway? Your changes will be lost.",
|
||||
@@ -61,7 +68,12 @@
|
||||
"enter_pin_placeholder": "Enter your PIN...",
|
||||
"decrypt_with_pin": "Decrypt",
|
||||
"use_recovery_instead": "Use recovery phrase instead",
|
||||
"error_incorrect_pin": "Incorrect PIN. Decryption failed."
|
||||
"error_incorrect_pin": "Incorrect PIN. Decryption failed.",
|
||||
"error_invalid_host": "Passkeys do not work on 127.0.0.1. Please open the app via localhost.",
|
||||
"use_localhost_link": "Switch to localhost",
|
||||
"error_passkey_cancelled": "Passkey sign-in was cancelled or timed out. Please try again.",
|
||||
"error_invalid_rp_id": "Passkey domain mismatch (RP ID). For local dev use http://localhost:5173 with RP_ID=localhost in .env.",
|
||||
"error_session_incomplete": "Sign-in incomplete. Please sign in with your passkey again."
|
||||
},
|
||||
"pwa": {
|
||||
"title": "Install app",
|
||||
@@ -84,6 +96,7 @@
|
||||
},
|
||||
"sync": {
|
||||
"status_synced": "Synced",
|
||||
"status_syncing": "Syncing…",
|
||||
"status_offline": "Offline Cache",
|
||||
"status_unsynced": "Unsynced changes"
|
||||
},
|
||||
@@ -116,7 +129,13 @@
|
||||
"no_sails": "No sails defined.",
|
||||
"photo_add": "Add Photo",
|
||||
"photo_change": "Change Photo",
|
||||
"photo_delete": "Delete Photo"
|
||||
"photo_delete": "Delete Photo",
|
||||
"tanks_section": "Tanks (capacity)",
|
||||
"tanks_help": "Optional, in liters — enables sliders in the journal when tank sizes are known.",
|
||||
"freshwater_capacity_l": "Freshwater (liters)",
|
||||
"fuel_capacity_l": "Fuel (liters)",
|
||||
"greywater_capacity_l": "Greywater (liters)",
|
||||
"invalid_tank_liters": "Invalid number — please enter capacity in liters (e.g. 200)."
|
||||
},
|
||||
"logs": {
|
||||
"title": "Logbook Journal",
|
||||
@@ -137,6 +156,10 @@
|
||||
"route": "Route / Journey",
|
||||
"freshwater": "Freshwater (Liters)",
|
||||
"fuel": "Fuel (Liters)",
|
||||
"greywater": "Greywater (Liters)",
|
||||
"greywater_level": "Fill level",
|
||||
"tank_slider_of_max": "{{current}} / {{max}} L",
|
||||
"tank_capacity_tooltip": "If tank capacities (liters) are set in vessel master data, you can enter fill levels here using sliders.",
|
||||
"morning": "Morning Level",
|
||||
"refilled": "Refilled",
|
||||
"evening": "Evening Level",
|
||||
@@ -179,10 +202,98 @@
|
||||
"saving": "Saving...",
|
||||
"saved": "Logbook page saved successfully!",
|
||||
"loading": "Loading journal...",
|
||||
"view_mode_label": "View",
|
||||
"view_list": "List",
|
||||
"live_mode": "Live",
|
||||
"live_title": "Live Journal",
|
||||
"live_loading": "Loading live journal...",
|
||||
"live_retry": "Try again",
|
||||
"live_load_error": "Could not load live journal.",
|
||||
"live_action_error": "Could not save entry.",
|
||||
"live_open_editor": "Full editor",
|
||||
"live_actions_label": "Quick actions",
|
||||
"live_stream_label": "Event log",
|
||||
"live_stream_title": "Journal",
|
||||
"live_no_events": "No entries yet — tap an action.",
|
||||
"live_motor_start": "Engine Start",
|
||||
"live_motor_stop": "Engine Stop",
|
||||
"live_cast_off": "Cast off",
|
||||
"live_moor": "Moor",
|
||||
"live_sails_btn": "Sails",
|
||||
"live_sails_pick": "Select sails",
|
||||
"live_sails_pick_hint": "Tap multiple sails (tap again to deselect), then log.",
|
||||
"live_sails_selected": "Selected: {{sails}}",
|
||||
"live_sails_confirm": "Log entry",
|
||||
"live_sails_confirm_count": "Log entry ({{count}})",
|
||||
"live_sails": "Sails: {{sails}}",
|
||||
"live_fix": "Fix",
|
||||
"live_fix_coords": "Fix {{lat}}, {{lng}}",
|
||||
"live_fix_manual_hint": "GPS unavailable. Enter latitude and longitude manually, or try again with the GPS button.",
|
||||
"live_fix_gps_loading": "Getting GPS position…",
|
||||
"live_fix_invalid": "Please enter valid coordinates (latitude −90…90, longitude −180…180).",
|
||||
"live_fix_lat_placeholder": "Latitude (Lat)",
|
||||
"live_fix_lng_placeholder": "Longitude (Lng)",
|
||||
"live_photo_btn": "Photo (camera)",
|
||||
"live_photo_capture_btn": "Capture",
|
||||
"live_photo_save_btn": "Save",
|
||||
"live_photo_retake_btn": "Retake",
|
||||
"live_photo_capture_failed": "Capture failed. Please try again.",
|
||||
"live_photo_open_camera_btn": "Open camera",
|
||||
"live_photo_native_hint": "Take a photo with your device camera, then save it here.",
|
||||
"live_photo_camera_starting": "Starting camera…",
|
||||
"live_photo_camera_denied": "Camera access denied or unavailable.",
|
||||
"live_photo_camera_unavailable": "Camera is not supported in this browser.",
|
||||
"live_photo_error": "Could not save photo.",
|
||||
"live_photo_entry": "Photo: {{caption}}",
|
||||
"live_photo_entry_plain": "Photo captured",
|
||||
"live_undo_photo_hint": "Photo saved",
|
||||
"live_comment_btn": "Comment",
|
||||
"live_comment_placeholder": "Enter text…",
|
||||
"live_comment_confirm": "Log entry",
|
||||
"live_gps_error": "Could not determine GPS position.",
|
||||
"live_event_generic": "Event",
|
||||
"live_weather_btn": "Weather",
|
||||
"live_weather_owm_btn": "Fetch OpenWeatherMap weather",
|
||||
"live_weather_owm_loading": "Loading weather…",
|
||||
"live_weather_fix_required": "Log a GPS fix first (Fix button) to fetch OpenWeatherMap weather. The position must be at most 6 hours old.",
|
||||
"live_weather_fix_stale": "The last GPS fix is older than 6 hours. Log a new fix before fetching weather.",
|
||||
"live_wind_btn": "Wind",
|
||||
"live_temp_btn": "Temp °C",
|
||||
"live_pressure_btn": "Pressure",
|
||||
"live_precip_btn": "Precipitation",
|
||||
"live_sea_state_btn": "Sea state",
|
||||
"live_course_btn": "Course",
|
||||
"live_fuel_btn": "Fuel",
|
||||
"live_water_btn": "Water",
|
||||
"live_wind_entry": "Wind {{value}}",
|
||||
"live_temp_entry": "Temperature {{temp}} °C",
|
||||
"live_pressure_entry": "Pressure {{value}} hPa",
|
||||
"live_precip_entry": "Precipitation {{value}}",
|
||||
"live_sea_state_entry": "Sea state {{value}}",
|
||||
"live_course_entry": "Course {{course}}",
|
||||
"live_fuel_entry": "Fuel +{{liters}} L",
|
||||
"live_water_entry": "Water +{{liters}} L",
|
||||
"live_auto_position": "Auto position",
|
||||
"live_undo_hint": "Entry saved",
|
||||
"live_undo_btn": "Undo",
|
||||
"live_pressure_placeholder": "e.g. 1013",
|
||||
"live_temp_placeholder": "e.g. 18",
|
||||
"live_precip_placeholder": "e.g. light rain",
|
||||
"live_sea_state_placeholder": "e.g. 3",
|
||||
"live_course_placeholder": "e.g. 245",
|
||||
"live_fuel_placeholder": "Liters refilled",
|
||||
"live_water_placeholder": "Liters refilled",
|
||||
"live_sog_btn": "SOG",
|
||||
"live_stw_btn": "STW",
|
||||
"live_sog_entry": "SOG {{speed}} kn",
|
||||
"live_stw_entry": "STW {{speed}} kn",
|
||||
"live_sog_placeholder": "e.g. 5.2",
|
||||
"live_stw_placeholder": "e.g. 4.8",
|
||||
"live_sog_hint": "Speed over ground (kn) — prefilled from GPS when available.",
|
||||
"delete_entry": "Delete Day",
|
||||
"delete_confirm": "Are you sure you want to permanently delete this travel day?",
|
||||
"carry_over_tanks_title": "Carry over from previous day?",
|
||||
"carry_over_tanks_confirm": "Use the previous travel day's destination as departure port and closing tank levels as morning levels?\n\nDeparture port: {{departure}}\nFreshwater: {{fw}} L\nFuel: {{fuel}} L",
|
||||
"carry_over_tanks_confirm": "Use the previous travel day's destination as departure port and closing tank levels as morning levels?\n\nDeparture port: {{departure}}\nFreshwater: {{fw}} L\nFuel: {{fuel}} L\nGreywater: {{greywater}} L",
|
||||
"carry_over_tanks_yes": "Carry over",
|
||||
"carry_over_tanks_no": "Start at 0",
|
||||
"event_title": "Chronological Event Logbook",
|
||||
@@ -190,6 +301,23 @@
|
||||
"event_time": "Time",
|
||||
"event_mgk": "MgK Course",
|
||||
"event_rwk": "RwK Course",
|
||||
"event_course_section": "Course",
|
||||
"course_dial_hint": "Drag the ring or enter degrees",
|
||||
"course_dial_step_label": "Step size",
|
||||
"course_step_fine": "1°",
|
||||
"course_step_medium": "5°",
|
||||
"course_step_coarse": "10°",
|
||||
"course_tab_mgk": "MgK",
|
||||
"course_tab_rwk": "rwK",
|
||||
"course_invalid": "Invalid course (0–360)",
|
||||
"course_placeholder_degrees": "e.g. 180",
|
||||
"course_placeholder_cardinal": "e.g. NW",
|
||||
"compass_n": "N",
|
||||
"compass_e": "E",
|
||||
"compass_s": "S",
|
||||
"compass_w": "W",
|
||||
"wind_mode_cardinal": "Cardinal",
|
||||
"wind_mode_degrees": "As degrees",
|
||||
"event_wind_direction": "Wind Dir",
|
||||
"event_wind_strength": "Wind Str",
|
||||
"event_sea_state": "Sea State",
|
||||
@@ -205,6 +333,8 @@
|
||||
"event_heel": "Heel Angle (°)",
|
||||
"event_sails": "Sails / Motor Status",
|
||||
"motor_propulsion": "Engine Propulsion",
|
||||
"sails_picker_show_more": "Show all sails",
|
||||
"sails_picker_show_less": "Show less",
|
||||
"motor_hours": "Engine hours (total)",
|
||||
"fuel_per_motor_hour": "Consumption per engine hour",
|
||||
"event_distance": "Distance (nm)",
|
||||
@@ -236,6 +366,56 @@
|
||||
"track_map_end": "End",
|
||||
"track_map_speed_slow": "slow",
|
||||
"track_map_speed_fast": "fast",
|
||||
"nmea_import_title": "Import NMEA log",
|
||||
"nmea_import_intro": "Upload a .nmea file from your onboard logger. The app suggests journal entries — you choose what to import.",
|
||||
"nmea_import_btn": "Import NMEA",
|
||||
"nmea_file_label": "NMEA file",
|
||||
"nmea_stats": "{{lines}} sentences parsed · types: {{types}}",
|
||||
"nmea_warn_no_position": "No position sentences found — track and GPS fields may stay empty.",
|
||||
"nmea_warn_duplicate_file": "This NMEA file has already been imported. Importing the same file again will add duplicate journal entries.",
|
||||
"nmea_mode_label": "Generate journal entries",
|
||||
"nmea_mode_interval": "By time interval",
|
||||
"nmea_mode_change": "On significant change",
|
||||
"nmea_mode_both": "Both (merge)",
|
||||
"nmea_interval_label": "Interval (minutes)",
|
||||
"nmea_import_track": "Import GPS track from NMEA",
|
||||
"nmea_preview": "Preview",
|
||||
"nmea_preview_hint": "{{count}} suggested journal entries",
|
||||
"nmea_select_all": "Select all",
|
||||
"nmea_select_none": "Select none",
|
||||
"nmea_source_interval": "Interval",
|
||||
"nmea_source_change": "Event",
|
||||
"nmea_apply": "Apply to journal",
|
||||
"nmea_back": "Back",
|
||||
"nmea_cancel": "Cancel",
|
||||
"nmea_archive_question": "Archive raw log locally? (This device only, not synced.)",
|
||||
"nmea_archive_keep": "Archive",
|
||||
"nmea_archive_discard": "Discard",
|
||||
"nmea_archive_stored": "NMEA archived: {{name}}",
|
||||
"nmea_archive_delete_confirm": "Delete archived NMEA log from this device?",
|
||||
"nmea_error_no_samples": "No usable NMEA sentences in the file.",
|
||||
"nmea_error_parse": "Could not read NMEA file.",
|
||||
"nmea_error_read": "Could not read file.",
|
||||
"nmea_error_no_file": "Please choose an NMEA file first.",
|
||||
"nmea_error_no_selection": "Please select at least one journal entry.",
|
||||
"nmea_remark_interval": "NMEA interval",
|
||||
"nmea_remark_uncertain": "uncertain",
|
||||
"nmea_remark_depth": "Depth {{depth}} m",
|
||||
"nmea_change_course": "Course change {{from}}° → {{to}}°",
|
||||
"nmea_change_wind": "Wind {{from}}° → {{to}}°",
|
||||
"nmea_change_wind_speed": "Wind {{from}} → {{to}} kn",
|
||||
"nmea_change_pressure": "Pressure {{from}} → {{to}} hPa",
|
||||
"nmea_change_depth": "Depth {{from}} → {{to}} m",
|
||||
"nmea_change_engine_start": "Engine on ({{rpm}} rpm)",
|
||||
"nmea_change_engine_stop": "Engine off",
|
||||
"nmea_change_autopilot_on": "Autopilot on",
|
||||
"nmea_change_autopilot_off": "Autopilot off",
|
||||
"nmea_change_gps_lost": "GPS fix lost",
|
||||
"nmea_change_gps_regained": "GPS fix restored",
|
||||
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
|
||||
"nmea_change_departure": "Departure / underway",
|
||||
"nmea_change_anchor": "Anchored / stop",
|
||||
"nmea_change_speed": "Speed {{from}} → {{to}} kn",
|
||||
"track_map_error": "Could not load map.",
|
||||
"exporting": "Exporting...",
|
||||
"share_unsupported": "Web sharing is not supported on this device. File downloaded instead.",
|
||||
@@ -274,7 +454,23 @@
|
||||
"edit_title": "Rename Logbook",
|
||||
"edit_placeholder": "New name of the logbook",
|
||||
"edit_success": "Logbook renamed successfully",
|
||||
"edit_btn": "Rename"
|
||||
"edit_btn": "Rename",
|
||||
"filter_label": "Filter logbooks",
|
||||
"filter_placeholder": "Name, year or date …",
|
||||
"filter_clear": "Clear filter",
|
||||
"filter_results": "{{count}} matches",
|
||||
"filter_no_results": "No logbooks match your search. Try a different name or year.",
|
||||
"sort_label": "Sort",
|
||||
"sort_by_label": "Sort by",
|
||||
"sort_by_name": "Name",
|
||||
"sort_by_date": "Date",
|
||||
"sort_dir_label": "Order",
|
||||
"sort_asc": "Ascending",
|
||||
"sort_desc": "Descending",
|
||||
"sort_name_asc": "Name A to Z",
|
||||
"sort_name_desc": "Name Z to A",
|
||||
"sort_date_asc": "Oldest first",
|
||||
"sort_date_desc": "Newest first"
|
||||
},
|
||||
"profile": {
|
||||
"title": "User profile",
|
||||
@@ -363,7 +559,35 @@
|
||||
"stats_subtitle": "Across all your logbooks on this device",
|
||||
"stats_logbooks": "Logbooks",
|
||||
"stats_account_since": "Account since",
|
||||
"stats_shared_logbooks": "Shared logbooks"
|
||||
"stats_shared_logbooks": "Shared logbooks",
|
||||
"appearance_title": "App & appearance",
|
||||
"appearance_desc": "Theme and color scheme apply to the entire app on this device.",
|
||||
"theme_label": "Application style / theme",
|
||||
"theme_auto": "Auto (OS detect)",
|
||||
"theme_ocean": "Ocean (glassmorphism)",
|
||||
"theme_material": "Material (Android)",
|
||||
"theme_cupertino": "Cupertino (iOS)",
|
||||
"color_scheme_label": "Light or dark mode",
|
||||
"color_scheme_auto": "Auto (system)",
|
||||
"color_scheme_light": "Light",
|
||||
"color_scheme_dark": "Dark",
|
||||
"integrations_title": "Integrations",
|
||||
"owm_key": "OpenWeatherMap API key",
|
||||
"owm_help": "Optional: your own OpenWeatherMap API key. If left empty, the operator-configured server key is used.",
|
||||
"prefs_save": "Save",
|
||||
"prefs_saving": "Saving…",
|
||||
"prefs_saved": "Saved",
|
||||
"tour_title": "App tour",
|
||||
"tour_desc": "Take a guided walkthrough of the main areas of the app again.",
|
||||
"tour_restart": "Restart tour",
|
||||
"push_title": "Push notifications",
|
||||
"push_desc": "As logbook owner you are notified when invited crew members sync changes. No logbook content is sent in plain text.",
|
||||
"push_enable": "Notify on crew changes",
|
||||
"push_active": "Push notifications are active on this device.",
|
||||
"push_unsupported": "Push notifications are not supported in this browser.",
|
||||
"push_denied_hint": "Notifications are blocked. Allow them in your browser or device settings.",
|
||||
"push_ios_install_hint": "On iPhone/iPad: add the app to your Home Screen (iOS 16.4+) to use push notifications.",
|
||||
"push_error": "Could not enable push notifications."
|
||||
},
|
||||
"crew": {
|
||||
"title": "Skipper & Crew Profiles",
|
||||
@@ -400,36 +624,22 @@
|
||||
"loading": "Loading calibration table..."
|
||||
},
|
||||
"settings": {
|
||||
"title": "System Settings",
|
||||
"subtitle": "Configure external integrations and client credentials.",
|
||||
"owm_title": "Weather Integration",
|
||||
"owm_key": "OpenWeatherMap API Key",
|
||||
"save": "Save Configuration",
|
||||
"saving": "Saving...",
|
||||
"saved": "Settings saved successfully!",
|
||||
"key_help": "Optional: your own OpenWeatherMap API key. If left empty, the operator-configured server key is used.",
|
||||
"no_key": "No OpenWeatherMap API key available. Add your own key in settings or contact the operator.",
|
||||
"title": "Logbook settings",
|
||||
"subtitle": "Sharing, backup, and collaboration for this logbook.",
|
||||
"select_logbook_hint": "Select a logbook to edit its settings.",
|
||||
"no_key": "No OpenWeatherMap API key available. Add your own key in your user profile or contact the operator.",
|
||||
"weather_success": "Weather details fetched successfully!",
|
||||
"weather_error": "Failed to fetch weather. Check your API key and connection.",
|
||||
"weather_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.",
|
||||
"theme_title": "UI Customization",
|
||||
"theme_label": "Application Style / Theme",
|
||||
"theme_auto": "Auto (OS Detect)",
|
||||
"theme_ocean": "Ocean (Glassmorphism)",
|
||||
"theme_material": "Material (Android)",
|
||||
"theme_cupertino": "Cupertino (iOS)",
|
||||
"color_scheme_title": "Appearance",
|
||||
"color_scheme_label": "Light or dark mode (default: follow system)",
|
||||
"color_scheme_auto": "Auto (System)",
|
||||
"color_scheme_light": "Light",
|
||||
"color_scheme_dark": "Dark",
|
||||
"share_title": "Share Logbook (Read-Only)",
|
||||
"share_desc": "Enable this to generate a public, read-only link. Anyone with the link can view your travels, yacht profile, and crew members. Decryption keys are never transmitted to the server (they stay in the hash part of the URL).",
|
||||
"share_privacy_warning": "Recommendation: Share this link only privately (e.g. via email or messenger), not on social media.",
|
||||
"share_enable": "Enable Public Link",
|
||||
"share_copied": "Link copied!",
|
||||
"share_copy_btn": "Copy Link",
|
||||
"link_qr_hint": "Scan this QR code with your phone",
|
||||
"link_qr_alt": "QR code for the link",
|
||||
"danger_zone_title": "Danger Zone",
|
||||
"danger_zone_desc": "Deleting your account will permanently delete all your passkeys, logbooks, vessel data, crew profiles, travel logs, and E2E keys. This action cannot be undone.",
|
||||
"delete_account_btn": "Permanently Delete Account",
|
||||
@@ -440,17 +650,12 @@
|
||||
"delete_account_failed": "Failed to delete account. Please try again.",
|
||||
"delete_backup_hint": "Tip: Before deleting, create backups of your logbooks (.daagbok.json) in each logbook's settings.",
|
||||
"deleting_account": "Deleting account…",
|
||||
"tour_title": "App tour",
|
||||
"tour_desc": "Take a guided walkthrough of the main areas of the app again.",
|
||||
"tour_restart": "Restart tour",
|
||||
"push_title": "Push notifications",
|
||||
"push_desc": "As logbook owner you are notified when invited crew members sync changes. No logbook content is sent in plain text.",
|
||||
"push_enable": "Notify on crew changes",
|
||||
"push_active": "Push notifications are active on this device.",
|
||||
"push_unsupported": "Push notifications are not supported in this browser.",
|
||||
"push_denied_hint": "Notifications are blocked. Allow them in your browser or device settings.",
|
||||
"push_ios_install_hint": "On iPhone/iPad: add the app to your Home Screen (iOS 16.4+) to use push notifications.",
|
||||
"push_error": "Could not enable push notifications.",
|
||||
"invite_push_prompt_title": "Enable push notifications?",
|
||||
"invite_push_prompt_message": "When invited crew members sync changes, you can be notified via push. No logbook content is sent in plain text.",
|
||||
"invite_push_prompt_ios_message": "When crew members sync changes, you can get push notifications. On iPhone/iPad: add the app to your Home Screen (iOS 16.4+), then enable push in your user profile.",
|
||||
"invite_push_prompt_enable": "Enable now",
|
||||
"invite_push_prompt_later": "Later",
|
||||
"invite_push_prompt_success": "Push notifications are active on this device.",
|
||||
"backup_title": "Backup & restore",
|
||||
"backup_desc": "Full encrypted backup of this logbook (entries, photos, GPS tracks, crew, vessel). Protected with a backup passphrase — restore on this or a new account.",
|
||||
"backup_export_title": "Create backup",
|
||||
@@ -514,6 +719,7 @@
|
||||
"category_general": "General",
|
||||
"category_bug": "Bug report",
|
||||
"category_feature": "Feature request",
|
||||
"category_translation": "Translation error",
|
||||
"contact_label": "Email (optional)",
|
||||
"contact_placeholder": "your@email.example",
|
||||
"message_label": "Message",
|
||||
@@ -602,7 +808,13 @@
|
||||
"unit_l": "L",
|
||||
"day_label": "Day {{day}}",
|
||||
"account_logbooks": "Logbooks overview",
|
||||
"col_logbook": "Logbook"
|
||||
"col_logbook": "Logbook",
|
||||
"event_series_title": "Event series",
|
||||
"event_series_hint": "Chronological values from the event log.",
|
||||
"event_series_pressure": "Barometric pressure",
|
||||
"event_series_wind": "Wind",
|
||||
"event_series_motor": "Engine",
|
||||
"event_series_empty": "No entries yet."
|
||||
},
|
||||
"tour": {
|
||||
"skip": "Skip tour",
|
||||
@@ -651,9 +863,17 @@
|
||||
"title": "Send feedback",
|
||||
"body": "Use this form to report bugs, ideas, or general feedback to the project team — you can also open it anytime later via the icon in the top right."
|
||||
},
|
||||
"nav_profile": {
|
||||
"title": "Your user profile",
|
||||
"body": "Tap the skipper button at the top to open your personal profile — independent of the current logbook."
|
||||
},
|
||||
"profile_preferences": {
|
||||
"title": "Account & appearance",
|
||||
"body": "Manage your account identity, theme, and light/dark mode here. You can restart the app tour anytime. Passkeys and security settings are further down on the profile page."
|
||||
},
|
||||
"finish": {
|
||||
"title": "You're all set!",
|
||||
"body": "You'll land on the statistics dashboard next. You can restart the tour anytime in Settings. Fair winds!"
|
||||
"body": "You'll land on the statistics dashboard next. You can restart the tour anytime from your user profile. Fair winds!"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,887 @@
|
||||
{
|
||||
"translation": {
|
||||
"app": {
|
||||
"name": "Kapteins Daagbok",
|
||||
"tagline": "Loggbok for private båter",
|
||||
"beta": "Beta",
|
||||
"beta_hint": "Betaversjon - funksjoner kan fortsatt endres"
|
||||
},
|
||||
"languages": {
|
||||
"de": "Deutsch",
|
||||
"en": "English",
|
||||
"da": "Dansk",
|
||||
"sv": "Svenska",
|
||||
"nb": "Norsk"
|
||||
},
|
||||
"common": {
|
||||
"unsaved_changes_title": "Ikke-lagrede endringer",
|
||||
"unsaved_changes_message": "Du har endringer som ikke er lagret. Vil du virkelig forlate siden? Endringene dine vil gå tapt.",
|
||||
"unsaved_changes_leave": "Oppgivelse",
|
||||
"unsaved_changes_stay": "Bli"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashbord",
|
||||
"vessel": "Skipsdata",
|
||||
"crew": "Mannskapsliste",
|
||||
"deviation": "Tabell over distraksjoner",
|
||||
"logs": "Loggbokoppføringer",
|
||||
"stats": "Statistikk",
|
||||
"settings": "Innstillinger"
|
||||
},
|
||||
"auth": {
|
||||
"welcome": "Velkommen til Kapteins Daagbok",
|
||||
"tagline": "Din sikre, E2E-krypterte maritime loggbok.",
|
||||
"register": "Registrer deg med Passkey",
|
||||
"login": "Logg inn med Passkey",
|
||||
"login_as": "Logg inn som {{name}}",
|
||||
"quick_login": "Rask innlogging",
|
||||
"forget_account": "Glemt konto på denne enheten",
|
||||
"not_user": "Ikke {{name}}?",
|
||||
"recovery_title": "Gjenopprettingsnøkkelen din",
|
||||
"recovery_warning": "VIKTIG: Skriv ned disse 12 ordene. Hvis du mister Passkey og disse ordene, kan du ikke gjenopprette dataene dine.",
|
||||
"confirm_recovery": "Jeg har skrevet ned ordene",
|
||||
"status_logged_in": "Innlogget",
|
||||
"status_logged_out": "Avlyst",
|
||||
"copied": "Oppfattet!",
|
||||
"copy_phrase": "Kopieringstast",
|
||||
"enter_recovery": "Skriv inn gjenopprettingsnøkkel",
|
||||
"recovery_fallback_warning": "Din Passkey har blitt autentisert, men enheten din støtter ikke maskinvarebasert nøkkelderivering. Skriv inn gjenopprettingsnøkkelen på 12 ord for å dekryptere loggboken.",
|
||||
"recovery_placeholder": "Skriv inn gjenopprettingsnøkkelen din, som består av 12 ord atskilt med mellomrom...",
|
||||
"back": "Tilbake",
|
||||
"decrypting": "Dekryptering...",
|
||||
"decrypt_logbook": "Dekryptere loggbok",
|
||||
"error_incorrect_recovery": "Feil gjenopprettingsnøkkel. Dekryptering mislyktes.",
|
||||
"error_decryption_failed": "Dekryptering mislyktes. Vennligst sjekk gjenopprettingsnøkkelen din.",
|
||||
"or_register": "eller registrer deg",
|
||||
"explore_demo": "Utforsk demoen uten konto",
|
||||
"username_placeholder": "Brukernavn / Skippernavn",
|
||||
"processing": "Behandling...",
|
||||
"help": "Hjelp",
|
||||
"setup_pin_title": "Konfigurer lokal PIN-kode (valgfritt)",
|
||||
"setup_pin_warning": "Siden enheten din ikke støtter direkte Passkey-nøkkelavledning, må du ellers skrive inn 12-ordsnøkkelen hver gang du logger deg på denne enheten. Konfigurer en lokal PIN-kode for å unngå dette.",
|
||||
"pin_placeholder": "E.G. 123456",
|
||||
"pin_label": "Lokal PIN-kode (4-8 sifre)",
|
||||
"save_pin": "Lagre PIN-kode og fortsett",
|
||||
"skip_pin": "Hopp over og bruk gjenoppretting",
|
||||
"enter_pin_title": "Dekrypter med PIN-kode",
|
||||
"enter_pin_warning": "Skriv inn din lokale PIN-kode for å låse opp dekrypteringsnøkkelen på denne enheten.",
|
||||
"enter_pin_placeholder": "Tast inn PIN-koden din...",
|
||||
"decrypt_with_pin": "Dekryptere",
|
||||
"use_recovery_instead": "Bruk gjenopprettingsnøkler i stedet",
|
||||
"error_incorrect_pin": "Feil PIN-kode. Dekryptering mislyktes.",
|
||||
"error_invalid_host": "Passkeys fungerer ikke via 127.0.0.1. Åpne appen via localhost.",
|
||||
"use_localhost_link": "Bytt til localhost",
|
||||
"error_passkey_cancelled": "Passkey-innlogging ble avbrutt eller utløp. Prøv igjen.",
|
||||
"error_invalid_rp_id": "Passkey-domene stemmer ikke (RP ID). Bruk http://localhost:5173 med RP_ID=localhost i .env for lokal utvikling.",
|
||||
"error_session_incomplete": "Innlogging ufullstendig. Logg inn med passkey igjen."
|
||||
},
|
||||
"pwa": {
|
||||
"title": "Installer app",
|
||||
"generic_benefit": "Installer Kapteins Daagbok på enheten din for raskere tilgang, frakoblet bruk og permanent lagring av data.",
|
||||
"ios_instructions": "På iPad/iPhone: Legg til appen på startskjermen, slik at loggbokdataene dine forblir beskyttet og appen starter som en vanlig app.",
|
||||
"ios_step_share": "Trykk på aksjesymbolet i Safari-linjen",
|
||||
"ios_step_add": "Velg \"Gå til startskjermen\"",
|
||||
"install_now": "Installer nå",
|
||||
"installing": "Installasjon...",
|
||||
"later": "Senere",
|
||||
"never": "Ikke vis mer",
|
||||
"platform_ios": "Installasjon via Safari",
|
||||
"platform_android": "Installasjon via nettleseren",
|
||||
"platform_desktop": "Installasjon som en desktop-app",
|
||||
"settings_section": "Installasjon av app",
|
||||
"update_title": "Oppdatering tilgjengelig",
|
||||
"update_desc": "En ny versjon av Kapteins Daagbok er klar. Oppdater for å få med de siste endringene.",
|
||||
"update_now": "Oppdater nå",
|
||||
"update_reloading": "Laster..."
|
||||
},
|
||||
"sync": {
|
||||
"status_synced": "Synkronisert",
|
||||
"status_syncing": "Synkroniser...",
|
||||
"status_offline": "Frakoblet hurtigbuffer",
|
||||
"status_unsynced": "Usynkroniserte endringer"
|
||||
},
|
||||
"vessel": {
|
||||
"title": "Stamdata for skip",
|
||||
"name": "Båtens navn",
|
||||
"type": "Båttype",
|
||||
"type_unset": "- ikke spesifisert -",
|
||||
"type_sailing": "Seilbåt",
|
||||
"type_motor": "Motorbåt",
|
||||
"length_m": "Lengde (m)",
|
||||
"draft_m": "Trekkraft (m)",
|
||||
"air_draft_m": "Høyde (m)",
|
||||
"invalid_metric": "Ugyldig tallverdi - angi meter som desimaltall (f.eks. 12,5).",
|
||||
"port": "Hjemmehavn",
|
||||
"owner": "Eier",
|
||||
"charter": "Charterselskap",
|
||||
"registration": "Nummerskilt/registreringsnummer",
|
||||
"callsign": "Radiokallesignal",
|
||||
"atis": "ATIS nr.",
|
||||
"mmsi": "MMSI-nr.",
|
||||
"save": "Lagre skipsdata",
|
||||
"saving": "...vil bli reddet...",
|
||||
"saved": "Skipsdata vellykket lagret!",
|
||||
"loading": "Skipsdata er lastet inn...",
|
||||
"sails_list": "Seil (eksisterende seil)",
|
||||
"sails_help": "Skriv inn seilene som er tilgjengelige på båten din her (f.eks. storseil, genua, fokk).",
|
||||
"add_sail": "Legg til seil",
|
||||
"sail_name_placeholder": "z. f.eks. storseil",
|
||||
"no_sails": "Ingen seil lagret.",
|
||||
"photo_add": "Legg til bilde",
|
||||
"photo_change": "Endre bilde",
|
||||
"photo_delete": "Slett bilde",
|
||||
"tanks_section": "Tanker (kapasitet)",
|
||||
"tanks_help": "Valgfritt i liter - muliggjør glidebryter i tidsskriftet for kjente tankstørrelser.",
|
||||
"freshwater_capacity_l": "Drikkevann (liter)",
|
||||
"fuel_capacity_l": "Drivstoff (liter)",
|
||||
"greywater_capacity_l": "Gråvann (liter)",
|
||||
"invalid_tank_liters": "Ugyldig tallverdi - skriv inn liter som et tall (f.eks. 200)."
|
||||
},
|
||||
"logs": {
|
||||
"title": "Loggbokdagbok",
|
||||
"new_entry": "Ny reisedag",
|
||||
"travel_details": "Detaljer om reisen",
|
||||
"add_event": "Legg til ny loggbokoppføring",
|
||||
"add_event_btn": "Legg til hendelse",
|
||||
"edit_event": "Rediger hendelse",
|
||||
"save_event_btn": "Lagre endring",
|
||||
"cancel_event_edit": "Avbryt",
|
||||
"delete_event": "Slett hendelse",
|
||||
"sign_cleared_skipper_re_sign_title": "Skippers signatur fjernet",
|
||||
"sign_cleared_skipper_re_sign": "Hendelsesloggen har blitt endret. Skipperens signatur er fjernet. Vennligst godkjenn på nytt.",
|
||||
"date": "dato",
|
||||
"day_of_travel": "Reisens dag / reisedag",
|
||||
"departure": "Starthavn (reise fra)",
|
||||
"destination": "Destinasjonsport (til)",
|
||||
"route": "Reise fra/til",
|
||||
"freshwater": "Ferskvann (liter)",
|
||||
"fuel": "Drivstoff / Drivstoff (liter)",
|
||||
"greywater": "Gråvann (liter)",
|
||||
"greywater_level": "Fyllingsnivå",
|
||||
"tank_slider_of_max": "{{current}} / {{max}} L",
|
||||
"tank_capacity_tooltip": "Hvis tankkapasiteten (liter) er lagret i skipsdataene, kan du angi fyllingsnivåene her ved hjelp av glidebryteren.",
|
||||
"morning": "Stå opp om morgenen",
|
||||
"refilled": "Påfyllt",
|
||||
"evening": "Kveldsstand",
|
||||
"consumption": "Daglig forbruk",
|
||||
"signatures": "Underskrifter / frigivelse",
|
||||
"sign_skipper": "Skippers signatur",
|
||||
"sign_crew": "Mannskapets signatur",
|
||||
"sign_hint": "Signer med finger, penn eller mus",
|
||||
"sign_clear": "Slett",
|
||||
"sign_export_image": "[Signatur]",
|
||||
"sign_with_passkey": "Utgivelse med Passkey",
|
||||
"sign_passkey_signing": "Passkey er forespurt...",
|
||||
"sign_passkey_signed": "Utgitt av {{username}}",
|
||||
"sign_passkey_export": "Passkey: {{username}} ({{date}})",
|
||||
"sign_attribution_export": "{{username}} ({{date}})",
|
||||
"sign_passkey_clear": "Fjern Passkey utgivelse",
|
||||
"sign_mode_passkey": "Passkey",
|
||||
"sign_mode_classic": "Klassisk",
|
||||
"sign_passkey_failed": "Passkey Utgivelsen mislyktes",
|
||||
"sign_passkey_cancelled": "Passkey Utgivelse kansellert",
|
||||
"sign_invalid": "Signaturen er ugyldig - innholdet har blitt endret",
|
||||
"sign_badge_skipper": "Skipper",
|
||||
"sign_badge_skipper_invalid": "Ugyldig",
|
||||
"sign_badge_skipper_title_valid": "Skipper har gitt ut",
|
||||
"sign_badge_skipper_title_invalid": "Skippersignaturen er ugyldig - innholdet har blitt endret",
|
||||
"sign_classic_or_passkey": "Valgfritt: klassisk signatur eller Passkey utgivelse ovenfor",
|
||||
"sign_crew_passkey_hint": "Besetningsmedlemmer med skrivetilgang kan frigjøre via Passkey.",
|
||||
"sign_offline_hint": "Passkey-Godkjenning krever Internett - klassisk signatur mulig offline",
|
||||
"sign_lock_notice": "Etter signering er det ikke mulig å gjøre endringer i loggbokoppføringen (unntatt bilder) uten at skipper og mannskap må signere på nytt.",
|
||||
"sign_lock_active": "Denne oppføringen er signert. Endringer i loggboken (unntatt bilder) fjerner automatisk skipperens og mannskapets signaturer.",
|
||||
"sign_lock_warning_title": "Bekreft signatur",
|
||||
"sign_lock_warning": "Etter signering er det ikke lenger mulig å gjøre endringer i loggbokoppføringen (unntatt bilder) uten at skipper og mannskap må signere på nytt.\n\nØnsker du å fortsette?",
|
||||
"sign_proceed": "Skilt",
|
||||
"sign_cancel": "Avbryt",
|
||||
"sign_cleared_re_sign_title": "Signaturer fjernet",
|
||||
"sign_cleared_re_sign": "Loggbokoppføringen har blitt endret. Skipperens og mannskapets signaturer er fjernet. Vennligst signer på nytt.",
|
||||
"no_entries": "Ingen loggbokoppføringer funnet for denne båten. Lag din første seilasdag!",
|
||||
"back_to_list": "Tilbake til tidsskriftlisten",
|
||||
"save": "Lagre loggbokside",
|
||||
"saving": "...vil bli reddet...",
|
||||
"saved": "Loggboksiden er vellykket lagret!",
|
||||
"loading": "Tidsskriftet lastes inn...",
|
||||
"view_mode_label": "Visning",
|
||||
"view_list": "Liste",
|
||||
"live_mode": "Live",
|
||||
"live_title": "Live-journal",
|
||||
"live_loading": "Live-journal lastes inn...",
|
||||
"live_retry": "Prøv igjen",
|
||||
"live_load_error": "Live-journal kunne ikke lastes inn.",
|
||||
"live_action_error": "Oppføringen kunne ikke lagres.",
|
||||
"live_open_editor": "Full editor",
|
||||
"live_actions_label": "Hurtighandlinger",
|
||||
"live_stream_label": "Hendelseslogg",
|
||||
"live_stream_title": "Journal",
|
||||
"live_no_events": "Ingen oppføringer ennå — trykk på en handling.",
|
||||
"live_motor_start": "Motor Start",
|
||||
"live_motor_stop": "Motor Stopp",
|
||||
"live_cast_off": "Avreise",
|
||||
"live_moor": "Anløp",
|
||||
"live_sails_btn": "Seil",
|
||||
"live_sails_pick": "Velg seil",
|
||||
"live_sails_pick_hint": "Trykk flere seil (trykk igjen for å fjerne), deretter loggfør.",
|
||||
"live_sails_selected": "Valgt: {{sails}}",
|
||||
"live_sails_confirm": "Loggfør",
|
||||
"live_sails_confirm_count": "Loggfør ({{count}})",
|
||||
"live_sails": "Seil: {{sails}}",
|
||||
"live_fix": "Fix",
|
||||
"live_fix_coords": "Fix {{lat}}, {{lng}}",
|
||||
"live_fix_manual_hint": "GPS ikke tilgjengelig. Skriv inn bredde- og lengdegrad manuelt, eller prøv igjen med GPS-knappen.",
|
||||
"live_fix_gps_loading": "Henter GPS-posisjon…",
|
||||
"live_fix_invalid": "Skriv inn gyldige koordinater (bredde −90…90, lengde −180…180).",
|
||||
"live_fix_lat_placeholder": "Bredde (Lat)",
|
||||
"live_fix_lng_placeholder": "Lengde (Lng)",
|
||||
"live_photo_btn": "Foto (kamera)",
|
||||
"live_photo_capture_btn": "Ta bilde",
|
||||
"live_photo_save_btn": "Lagre",
|
||||
"live_photo_retake_btn": "Ta på nytt",
|
||||
"live_photo_capture_failed": "Opptak mislyktes. Prøv igjen.",
|
||||
"live_photo_open_camera_btn": "Åpne kamera",
|
||||
"live_photo_native_hint": "Ta et bilde med enhetskameraet og lagre det her etterpå.",
|
||||
"live_photo_camera_starting": "Starter kamera…",
|
||||
"live_photo_camera_denied": "Kameratilgang nektet eller utilgjengelig.",
|
||||
"live_photo_camera_unavailable": "Kamera støttes ikke i denne nettleseren.",
|
||||
"live_photo_error": "Kunne ikke lagre foto.",
|
||||
"live_photo_entry": "Foto: {{caption}}",
|
||||
"live_photo_entry_plain": "Foto tatt",
|
||||
"live_undo_photo_hint": "Foto lagret",
|
||||
"live_comment_btn": "Kommentar",
|
||||
"live_comment_placeholder": "Skriv inn tekst…",
|
||||
"live_comment_confirm": "Loggfør",
|
||||
"live_gps_error": "GPS-posisjon kunne ikke bestemmes.",
|
||||
"live_event_generic": "Hendelse",
|
||||
"live_weather_btn": "Vær",
|
||||
"live_weather_owm_btn": "Hent OpenWeatherMap-vær",
|
||||
"live_weather_owm_loading": "Henter vær…",
|
||||
"live_weather_fix_required": "Logg først en GPS-fix (Fix-knapp) for å hente OpenWeatherMap-vær. Posisjonen må være maks 6 timer gammel.",
|
||||
"live_weather_fix_stale": "Siste GPS-fix er eldre enn 6 timer. Logg en ny fix før du henter vær.",
|
||||
"live_wind_btn": "Vind",
|
||||
"live_temp_btn": "T °C",
|
||||
"live_pressure_btn": "Lufttrykk",
|
||||
"live_precip_btn": "Nedbør",
|
||||
"live_sea_state_btn": "Sjøgang",
|
||||
"live_course_btn": "Kurs",
|
||||
"live_fuel_btn": "Diesel",
|
||||
"live_water_btn": "Vann",
|
||||
"live_wind_entry": "Vind {{value}}",
|
||||
"live_temp_entry": "Temperatur {{temp}} °C",
|
||||
"live_pressure_entry": "Lufttrykk {{value}} hPa",
|
||||
"live_precip_entry": "Nedbør {{value}}",
|
||||
"live_sea_state_entry": "Sjøgang {{value}}",
|
||||
"live_course_entry": "Kurs {{course}}",
|
||||
"live_fuel_entry": "Diesel +{{liters}} L",
|
||||
"live_water_entry": "Vann +{{liters}} L",
|
||||
"live_auto_position": "Auto-posisjon",
|
||||
"live_undo_hint": "Oppføring lagret",
|
||||
"live_undo_btn": "Angre",
|
||||
"live_pressure_placeholder": "f.eks. 1013",
|
||||
"live_temp_placeholder": "f.eks. 18",
|
||||
"live_precip_placeholder": "f.eks. lett regn",
|
||||
"live_sea_state_placeholder": "f.eks. 3",
|
||||
"live_course_placeholder": "f.eks. 245",
|
||||
"live_fuel_placeholder": "Påfylte liter",
|
||||
"live_water_placeholder": "Påfylte liter",
|
||||
"live_sog_btn": "SOG",
|
||||
"live_stw_btn": "STW",
|
||||
"live_sog_entry": "SOG {{speed}} kn",
|
||||
"live_stw_entry": "STW {{speed}} kn",
|
||||
"live_sog_placeholder": "f.eks. 5,2",
|
||||
"live_stw_placeholder": "f.eks. 4,8",
|
||||
"live_sog_hint": "Fart over grunn (kn) — GPS-verdi fylles inn hvis tilgjengelig.",
|
||||
"delete_entry": "Slett tagg",
|
||||
"delete_confirm": "Er du sikker på at du vil slette denne reisedagen permanent?",
|
||||
"carry_over_tanks_title": "Overføre data fra dagen før?",
|
||||
"carry_over_tanks_confirm": "Overta starthavn, ferskvann, drivstoff og gråvann fra startnivåene fra siste dag på turen?\n\nStart havn: {{departure}}\nFerskvann: {{fw}} L\nDrivstoff: {{fuel}} L\nGråvann: {{greywater}} L",
|
||||
"carry_over_tanks_yes": "Ta over",
|
||||
"carry_over_tanks_no": "Begynn med 0",
|
||||
"event_title": "Kronologisk hendelseslogg",
|
||||
"no_events": "Ingen arrangementer lagt inn for denne reisedagen ennå.",
|
||||
"event_time": "Tid på døgnet",
|
||||
"event_mgk": "MgK-kurs",
|
||||
"event_rwk": "RwK-kurs",
|
||||
"event_course_section": "Kurs",
|
||||
"course_dial_hint": "Vri ringen eller angi grader",
|
||||
"course_dial_step_label": "Trinnstørrelse",
|
||||
"course_step_fine": "1°",
|
||||
"course_step_medium": "5°",
|
||||
"course_step_coarse": "10°",
|
||||
"course_tab_mgk": "MgK",
|
||||
"course_tab_rwk": "rwK",
|
||||
"course_invalid": "Ugyldig kurs (0-360)",
|
||||
"course_placeholder_degrees": "z. B. 180",
|
||||
"course_placeholder_cardinal": "z. E.G. NW",
|
||||
"compass_n": "N",
|
||||
"compass_e": "O",
|
||||
"compass_s": "S",
|
||||
"compass_w": "W",
|
||||
"wind_mode_cardinal": "Kardinal",
|
||||
"wind_mode_degrees": "Som grad",
|
||||
"event_wind_direction": "Vindretning",
|
||||
"event_wind_strength": "Vindstyrke",
|
||||
"event_sea_state": "Havets tilstand",
|
||||
"event_weather": "Været",
|
||||
"event_log": "Logg (sm)",
|
||||
"event_gps": "GPS-posisjon",
|
||||
"event_location": "Sted / havn",
|
||||
"event_location_placeholder": "z. f.eks. Kiel",
|
||||
"event_remarks": "Merknader / hendelser",
|
||||
"gps_btn": "Hent GPS-koordinater",
|
||||
"weather_btn": "OpenWeatherMap Ring opp været",
|
||||
"event_wind_pressure": "Lufttrykk (hPa)",
|
||||
"event_heel": "Helning (°)",
|
||||
"event_sails": "Seilhåndtering / motor",
|
||||
"motor_propulsion": "Maskinreise",
|
||||
"sails_picker_show_more": "Vis alle seil",
|
||||
"sails_picker_show_less": "Vis mindre",
|
||||
"motor_hours": "Maskintimer (totalt)",
|
||||
"fuel_per_motor_hour": "Forbruk per maskintime",
|
||||
"event_distance": "Avstand (sm)",
|
||||
"export_csv": "Last ned CSV",
|
||||
"share_csv": "CSV andel",
|
||||
"export_pdf": "Last ned PDF",
|
||||
"exporting_pdf": "PDF genereres...",
|
||||
"photos_title": "Bildevedlegg (E2E-kryptert)",
|
||||
"photo_caption_label": "Fotobeskrivelse/etikett (valgfritt)",
|
||||
"photo_caption_placeholder": "f.eks. å sette seil nær innseilingen til havnen",
|
||||
"photo_btn": "Ta bilde / last opp",
|
||||
"photo_processing": "...blir behandlet...",
|
||||
"no_photos": "Ingen bilder knyttet til denne reisedagen ennå.",
|
||||
"photo_delete_confirm": "Er du sikker på at du vil slette dette bildet permanent?",
|
||||
"confirm_yes": "Ja",
|
||||
"confirm_no": "Nei",
|
||||
"track_upload_title": "GPS-sporing (fil)",
|
||||
"track_upload_points": "Poeng",
|
||||
"gps_tracking_btn_gpx": "Last ned sporfil",
|
||||
"gps_track_upload_help": "Dra en GPX-, KML- eller GeoJSON-fil hit, eller klikk for å velge",
|
||||
"gps_track_upload_btn": "Last opp GPS-spor",
|
||||
"gps_track_delete": "Slett sporfil",
|
||||
"gps_track_delete_confirm": "Er du sikker på at du vil slette denne sporfilen permanent?",
|
||||
"track_distance": "GPS-rute (sm)",
|
||||
"track_speed_max": "Maks. Hastighet (kn)",
|
||||
"track_speed_avg": "Ø Hastighet (kn)",
|
||||
"track_map_title": "GPS-spor på OpenSeaMap",
|
||||
"track_map_start": "Start",
|
||||
"track_map_end": "Mål",
|
||||
"track_map_speed_slow": "langsomt",
|
||||
"track_map_speed_fast": "raskt",
|
||||
"track_map_error": "Kartet kunne ikke lastes inn.",
|
||||
"exporting": "Eksport...",
|
||||
"share_unsupported": "Deling støttes ikke på denne enheten. Filen har blitt lastet ned i stedet.",
|
||||
"invite_crew": "Inviter mannskapet",
|
||||
"invite_link_copied": "Invitasjonslenke kopiert til utklippstavlen!",
|
||||
"invite_link_desc": "Del denne lenken med besetningsmedlemmene for å gi dem skrivetilgang til loggboken.",
|
||||
"collaborators_list": "Medlemmer / Besetning",
|
||||
"revoke": "Fjern",
|
||||
"revoke_confirm": "Er du sikker på at du vil oppheve dette besetningsmedlemmets tilgang?",
|
||||
"invite_role": "Rolle",
|
||||
"invite_expires": "Lenken er gyldig i 48 timer",
|
||||
"nmea_import_title": "Import NMEA log",
|
||||
"nmea_import_intro": "Upload a .nmea file from your onboard logger. The app suggests journal entries — you choose what to import.",
|
||||
"nmea_import_btn": "Import NMEA",
|
||||
"nmea_file_label": "NMEA file",
|
||||
"nmea_stats": "{{lines}} sentences parsed · types: {{types}}",
|
||||
"nmea_warn_no_position": "No position sentences found — track and GPS fields may stay empty.",
|
||||
"nmea_mode_label": "Generate journal entries",
|
||||
"nmea_mode_interval": "By time interval",
|
||||
"nmea_mode_change": "On significant change",
|
||||
"nmea_mode_both": "Both (merge)",
|
||||
"nmea_interval_label": "Interval (minutes)",
|
||||
"nmea_import_track": "Import GPS track from NMEA",
|
||||
"nmea_preview": "Preview",
|
||||
"nmea_preview_hint": "{{count}} suggested journal entries",
|
||||
"nmea_select_all": "Select all",
|
||||
"nmea_select_none": "Select none",
|
||||
"nmea_source_interval": "Interval",
|
||||
"nmea_source_change": "Event",
|
||||
"nmea_apply": "Apply to journal",
|
||||
"nmea_back": "Back",
|
||||
"nmea_cancel": "Cancel",
|
||||
"nmea_archive_question": "Archive raw log locally? (This device only, not synced.)",
|
||||
"nmea_archive_keep": "Archive",
|
||||
"nmea_archive_discard": "Discard",
|
||||
"nmea_archive_stored": "NMEA archived: {{name}}",
|
||||
"nmea_archive_delete_confirm": "Delete archived NMEA log from this device?",
|
||||
"nmea_error_no_samples": "No usable NMEA sentences in the file.",
|
||||
"nmea_error_parse": "Could not read NMEA file.",
|
||||
"nmea_error_read": "Could not read file.",
|
||||
"nmea_error_no_file": "Please choose an NMEA file first.",
|
||||
"nmea_error_no_selection": "Please select at least one journal entry.",
|
||||
"nmea_remark_interval": "NMEA interval",
|
||||
"nmea_remark_uncertain": "uncertain",
|
||||
"nmea_remark_depth": "Depth {{depth}} m",
|
||||
"nmea_change_course": "Course change {{from}}° → {{to}}°",
|
||||
"nmea_change_wind": "Wind {{from}}° → {{to}}°",
|
||||
"nmea_change_wind_speed": "Wind {{from}} → {{to}} kn",
|
||||
"nmea_change_pressure": "Pressure {{from}} → {{to}} hPa",
|
||||
"nmea_change_depth": "Depth {{from}} → {{to}} m",
|
||||
"nmea_change_engine_start": "Engine on ({{rpm}} rpm)",
|
||||
"nmea_change_engine_stop": "Engine off",
|
||||
"nmea_change_autopilot_on": "Autopilot on",
|
||||
"nmea_change_autopilot_off": "Autopilot off",
|
||||
"nmea_change_gps_lost": "GPS fix lost",
|
||||
"nmea_change_gps_regained": "GPS fix restored",
|
||||
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
|
||||
"nmea_change_departure": "Departure / underway",
|
||||
"nmea_change_anchor": "Anchored / stop",
|
||||
"nmea_change_speed": "Speed {{from}} → {{to}} kn",
|
||||
"nmea_warn_duplicate_file": "This NMEA file has already been imported. Importing the same file again will add duplicate journal entries."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Loggbøkene dine",
|
||||
"subtitle": "Velg en loggbok eller opprett en ny for å administrere reisene dine.",
|
||||
"create_btn": "Opprett loggbok",
|
||||
"new_logbook_placeholder": "Navn på loggboken eller båten",
|
||||
"logout": "Logg ut",
|
||||
"logged_in_as": "Innlogget som {{name}}",
|
||||
"delete_confirm": "Er du sikker på at du vil slette denne loggboken permanent? Alle lokale data og serverkopier vil bli ødelagt.\n\nTips: Lag en sikkerhetskopi (.daagbok.json) på forhånd under Innstillinger → Sikkerhetskopiering og gjenoppretting hvis du ønsker å beholde dataene senere.",
|
||||
"no_logbooks": "Ingen loggbøker funnet. Opprett din første loggbok for å komme i gang!",
|
||||
"loading": "Loggbøker er lastet...",
|
||||
"status_synced": "Synkronisert",
|
||||
"status_local": "Kun lokal hurtigbuffer",
|
||||
"delete_btn": "Slett loggbok",
|
||||
"section_owned": "Loggbøkene mine",
|
||||
"section_shared": "Felles loggbøker",
|
||||
"section_shared_hint": "Du er invitert som besetningsmedlem. Skipperprofil og innstillinger tilhører eieren.",
|
||||
"role_owner": "Egen loggbok",
|
||||
"role_owner_hint": "Du er eier og skipper av denne loggboken",
|
||||
"role_crew": "Tilgang for mannskapet",
|
||||
"role_crew_hint": "Loggbok med invitasjon - du kan jobbe som mannskap og signere den",
|
||||
"role_read": "Bare les",
|
||||
"role_read_hint": "Delt loggbok - kun visning, ingen redigering",
|
||||
"open_profile": "Åpne profilen til {{name}}",
|
||||
"edit_title": "Endre navn på loggbok",
|
||||
"edit_placeholder": "Nytt navn på loggboken",
|
||||
"edit_success": "Loggboken har fått nytt navn",
|
||||
"edit_btn": "Gi nytt navn",
|
||||
"filter_label": "Filtrer loggbøker",
|
||||
"filter_placeholder": "Navn, årstall eller dato ...",
|
||||
"filter_clear": "Tilbakestill filter",
|
||||
"filter_results": "{{count}} Treff",
|
||||
"filter_no_results": "Ingen loggbøker samsvarer med søket ditt. Prøv et annet navn eller et annet år.",
|
||||
"sort_label": "Sortere",
|
||||
"sort_by_label": "Sorter etter",
|
||||
"sort_by_name": "Navn",
|
||||
"sort_by_date": "dato",
|
||||
"sort_dir_label": "Sekvens",
|
||||
"sort_asc": "Stigende",
|
||||
"sort_desc": "Synkende",
|
||||
"sort_name_asc": "Navn A til Å",
|
||||
"sort_name_desc": "Navn Z til A",
|
||||
"sort_date_asc": "Eldst først",
|
||||
"sort_date_desc": "Nyeste først"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Brukerprofil",
|
||||
"subtitle": "Regnskap, Passkeys og statistikk for {{name}}.",
|
||||
"back": "Tilbake til dashbordet",
|
||||
"loading": "Profilen lastes inn...",
|
||||
"load_error": "Profilen kunne ikke lastes inn.",
|
||||
"copy_failed": "Kopien mislyktes.",
|
||||
"processing": "Blir behandlet...",
|
||||
"identity_title": "Kontoidentitet",
|
||||
"username": "Brukernavn",
|
||||
"user_id": "Bruker-ID",
|
||||
"copy_user_id": "Kopier bruker-ID",
|
||||
"account_since": "Konto siden",
|
||||
"prf_status": "Passkey nøkkelavledning (PRF)",
|
||||
"prf_active": "Aktiv",
|
||||
"prf_inactive": "Ikke satt opp",
|
||||
"passkeys_title": "Passkeys",
|
||||
"passkeys_desc": "Registrer en separat Passkey på hver enhet. Dette gjør at du kan logge på selv etter at du har byttet plattform.",
|
||||
"passkeys_empty": "Ingen Passkeyer funnet.",
|
||||
"add_passkey_btn": "Legg til ny Passkey",
|
||||
"add_passkey_success": "Passkey vellykket lagt til.",
|
||||
"add_passkey_failed": "Passkey kunne ikke legges til.",
|
||||
"remove_passkey_btn": "Fjern Passkey",
|
||||
"remove_passkey_last_title": "Sist Passkey",
|
||||
"remove_passkey_last_desc": "Den eneste Passkey kan ikke fjernes uten at du mister tilgangen til kontoen din. For å slette kontoen helt, bruk faresonen nederst på denne siden.",
|
||||
"remove_passkey_failed": "Passkey kunne ikke fjernes.",
|
||||
"remove_passkey_confirm_title": "Fjern Passkey?",
|
||||
"remove_passkey_confirm_desc": "Denne enheten kan da ikke lenger logge inn med denne Passkey.",
|
||||
"remove_passkey_confirm_yes": "Fjern",
|
||||
"remove_passkey_confirm_no": "Avbryt",
|
||||
"pin_title": "Lokal PIN-kode",
|
||||
"pin_status": "Status",
|
||||
"pin_active": "Aktiv på denne enheten",
|
||||
"pin_inactive": "Ikke satt opp",
|
||||
"pin_confirm_label": "Bekreft PIN-kode",
|
||||
"pin_confirm_placeholder": "Tast inn PIN-koden på nytt",
|
||||
"pin_set_btn": "Konfigurer PIN-kode",
|
||||
"pin_change_btn": "Endre PIN-kode",
|
||||
"pin_remove_btn": "Fjern PIN-kode",
|
||||
"pin_saved": "PIN-kode lagret.",
|
||||
"pin_save_failed": "PIN-koden kunne ikke lagres.",
|
||||
"pin_mismatch": "PIN-kodene stemmer ikke overens.",
|
||||
"pin_length_error": "PIN-koden må bestå av minst 4 tegn.",
|
||||
"pin_no_session": "Økten er utløpt - vennligst registrer deg på nytt.",
|
||||
"remove_pin_confirm_title": "Fjerne PIN-kode?",
|
||||
"remove_pin_confirm_desc": "Du må logge på igjen på denne enheten med Passkey eller gjenopprettingsnøkkel.",
|
||||
"remove_pin_confirm_yes": "Fjern PIN-kode",
|
||||
"remove_pin_confirm_no": "Avbryt",
|
||||
"security_title": "Sjekkliste for sikkerhet",
|
||||
"security_desc": "Oversikt over de viktigste beskyttelsesmekanismene for kontoen din.",
|
||||
"security_passkeys_ok": "Minst én Passkey registrert",
|
||||
"security_passkeys_missing": "Nei Passkey registrert",
|
||||
"security_prf_ok": "PRF-nøkkelavledning aktiv",
|
||||
"security_prf_missing": "PRF ikke satt opp",
|
||||
"security_pin_ok": "Lokal PIN-kode på denne enheten",
|
||||
"security_pin_missing": "Ingen lokal PIN-kode",
|
||||
"security_recovery_ok": "Oppsett av gjenopprettingsnøkkel",
|
||||
"security_recovery_hint": "De 12 ordene ble vist under registreringen. Oppbevar dem frakoblet og adskilt fra enheten. Du kan opprette en ny nøkkel nedenfor - den gamle blir da ugyldig.",
|
||||
"recovery_rotate_btn": "Opprett en ny gjenopprettingsnøkkel",
|
||||
"recovery_rotate_confirm_title": "Opprette en ny gjenopprettingsnøkkel?",
|
||||
"recovery_rotate_confirm_desc": "Den forrige 12-ordsnøkkelen blir ugyldig umiddelbart. Sørg for at du oppbevarer den nye nøkkelen trygt før du fortsetter.",
|
||||
"recovery_rotate_confirm_yes": "Opprett ny nøkkel",
|
||||
"recovery_rotate_confirm_no": "Avbryt",
|
||||
"recovery_rotate_new_warning": "VIKTIG: Skriv ned disse 12 ordene og oppbevar dem offline. Den forrige gjenopprettingsnøkkelen er nå ugyldig.",
|
||||
"recovery_rotate_failed": "Gjenopprettingsnøkkel kunne ikke opprettes.",
|
||||
"recovery_rotate_no_session": "Krypteringsøkten er utløpt - logg ut og logg inn igjen, og prøv deretter på nytt.",
|
||||
"device_title": "Denne enheten",
|
||||
"device_desc": "Lokal hurtigbuffer, synkroniseringsstatus og hurtigpålogging i denne nettleseren.",
|
||||
"device_sync_pending": "{{count}} ventende synkroniseringsoppføringer",
|
||||
"device_sync_ok": "Alle lokale endringer synkroniseres",
|
||||
"device_remembered": "Konto for hurtiginnlogging lagret på denne enheten",
|
||||
"device_not_remembered": "Kontoen er ikke i hurtiginnloggingslisten",
|
||||
"device_forget_btn": "Glemt konto på denne enheten",
|
||||
"device_forget_confirm_title": "Fjerne hurtiginnlogging?",
|
||||
"device_forget_confirm_desc": "Kontoen forsvinner fra hurtiginnloggingslisten på denne enheten. Økten og de lokale loggbøkene beholdes.",
|
||||
"device_forget_confirm_yes": "Fjern",
|
||||
"device_forget_confirm_no": "Avbryt",
|
||||
"passkey_label": "Navn på ny Passkey (valgfritt)",
|
||||
"passkey_label_placeholder": "z. f.eks. MacBook, iPhone",
|
||||
"passkey_rename_btn": "Lagre navn",
|
||||
"passkey_rename_success": "Passkey navn lagret.",
|
||||
"passkey_rename_failed": "Passkey-Navn kunne ikke lagres.",
|
||||
"passkey_unnamed": "Uten tittel Passkey",
|
||||
"stats_title": "Statistikk",
|
||||
"stats_subtitle": "Om alle loggbøkene dine på denne enheten",
|
||||
"stats_logbooks": "Loggbøker",
|
||||
"stats_account_since": "Konto siden",
|
||||
"stats_shared_logbooks": "Felles loggbøker",
|
||||
"appearance_title": "App og visualisering",
|
||||
"appearance_desc": "Designet og fargevalget gjelder for hele appen på denne enheten.",
|
||||
"theme_label": "Appens designstil",
|
||||
"theme_auto": "Automatisk (OS-deteksjon)",
|
||||
"theme_ocean": "Ocean (glassmorfisme)",
|
||||
"theme_material": "Materiale (Android)",
|
||||
"theme_cupertino": "Cupertino (iOS)",
|
||||
"color_scheme_label": "Lys eller mørk modus",
|
||||
"color_scheme_auto": "Automatisk (system)",
|
||||
"color_scheme_light": "Lys",
|
||||
"color_scheme_dark": "Mørk",
|
||||
"integrations_title": "Integrasjoner",
|
||||
"owm_key": "OpenWeatherMap API-nøkkel",
|
||||
"owm_help": "Valgfritt: egen OpenWeatherMap API-nøkkel. Hvis ingen oppføring er gjort, brukes serverside-nøkkelen fra operatørkonfigurasjonen.",
|
||||
"prefs_save": "Spar",
|
||||
"prefs_saving": "...vil bli reddet...",
|
||||
"prefs_saved": "Reddet",
|
||||
"tour_title": "App-tur",
|
||||
"tour_desc": "La deg veilede gjennom de viktigste områdene i appen på nytt.",
|
||||
"tour_restart": "Start turen på nytt",
|
||||
"push_title": "Push-varsler",
|
||||
"push_desc": "Som loggbokseier vil du bli varslet når inviterte besetningsmedlemmer synkroniserer endringer. Ingen innhold overføres i ren tekst.",
|
||||
"push_enable": "Gi oss beskjed om endringer i mannskapet",
|
||||
"push_active": "Push-varsler er aktive på denne enheten.",
|
||||
"push_unsupported": "Push-varsler støttes ikke i denne nettleseren.",
|
||||
"push_denied_hint": "Varsler er blokkert. Tillat dem i innstillingene i nettleseren eller på enheten.",
|
||||
"push_ios_install_hint": "På iPhone/iPad: Legg til app på startskjermen (iOS 16.4+) for å bruke push.",
|
||||
"push_error": "Push-varsler kunne ikke aktiveres."
|
||||
},
|
||||
"crew": {
|
||||
"title": "Skipper- og mannskapsprofiler",
|
||||
"skipper_section": "Skipperprofil",
|
||||
"skipper_read_only_hint": "Skipperprofilen kan bare redigeres av eieren av loggboken.",
|
||||
"crew_section": "Mannskapsliste",
|
||||
"add_crew": "Legg til besetningsmedlem",
|
||||
"edit_crew": "Rediger besetningsmedlem",
|
||||
"no_crew": "Ingen besetningsmedlemmer er lagt til ennå.",
|
||||
"max_crew": "Maksimalt antall på 5 besetningsmedlemmer er nådd.",
|
||||
"name": "Navn",
|
||||
"address": "adresse",
|
||||
"birthdate": "Bursdag",
|
||||
"phone": "Telefonnummer",
|
||||
"nationality": "Nasjonalitet",
|
||||
"passport": "Pass-/ID-nummer",
|
||||
"bloodtype": "Blodgruppe",
|
||||
"allergies": "Allergier",
|
||||
"diseases": "Eksisterende tilstander/sykdommer",
|
||||
"save": "Lagre skipperdata",
|
||||
"save_member": "Lagre medlem",
|
||||
"saved": "Skipperprofilen er vellykket lagret!",
|
||||
"loading": "Mannskapsfilene er lastet inn...",
|
||||
"delete_confirm": "Er du sikker på at du vil fjerne dette besetningsmedlemmet?"
|
||||
},
|
||||
"deviation": {
|
||||
"title": "Tabell over kompassavvik",
|
||||
"subtitle": "Angi den magnetiske kompassavbøyningen (avbøyning) for kurser (MgK) fra 000° til 360° i trinn på 10°.",
|
||||
"heading": "MgK",
|
||||
"deviation": "Distraksjon",
|
||||
"save": "Lagre kalibreringsrutenettet",
|
||||
"saving": "...vil bli reddet...",
|
||||
"saved": "Kalibreringsrutenettet er vellykket lagret!",
|
||||
"loading": "Kalibreringstabellen er lastet inn..."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Innstillinger for loggbok",
|
||||
"subtitle": "Del, sikkerhetskopier og samarbeid for denne loggboken.",
|
||||
"select_logbook_hint": "Velg en loggbok for å redigere innstillingene.",
|
||||
"no_key": "Ingen OpenWeatherMap API-nøkkel tilgjengelig. Lagre din egen nøkkel i brukerprofilen, eller kontakt operatøren.",
|
||||
"weather_success": "Værdata vellykket hentet!",
|
||||
"weather_error": "Henting av værdata mislyktes. Kontroller API-nøkkelen og tilkoblingen.",
|
||||
"weather_date_mismatch": "Værdata kan bare hentes ut for i dag ({{today}}). Denne loggbokoppføringen er datert {{date}}.",
|
||||
"gps_error": "Vennligst skriv inn en posisjon eller finn GPS-koordinatene.",
|
||||
"share_title": "Del loggbok (skrivebeskyttet)",
|
||||
"share_desc": "Aktiver dette alternativet for å opprette en offentlig, skrivebeskyttet lenke. Alle som har denne lenken, kan se seilasene, båtprofilene og mannskapet ditt. Krypteringsnøklene overføres aldri til serveren (de forblir i hash-delen av URL-en).",
|
||||
"share_privacy_warning": "Anbefaling: Del denne lenken kun privat (f.eks. via e-post eller messenger), ikke på sosiale medier.",
|
||||
"share_enable": "Aktiver offentlig lenke",
|
||||
"share_copied": "Linken er kopiert!",
|
||||
"share_copy_btn": "Kopier lenke",
|
||||
"link_qr_hint": "Skann QR-koden med telefonen",
|
||||
"link_qr_alt": "QR-kode for lenken",
|
||||
"danger_zone_title": "Faresone",
|
||||
"danger_zone_desc": "Hvis du sletter kontoen din, slettes alle dine Passkeys, loggbøker, skipsdata, mannskapsprofiler, reiseoppføringer og E2E-nøkler ugjenkallelig. Denne handlingen kan ikke angres.",
|
||||
"delete_account_btn": "Slett konto ugjenkallelig",
|
||||
"delete_account_confirm_title": "Slett konto?",
|
||||
"delete_account_confirm_desc": "Er du helt sikker på at du vil slette kontoen din og alle tilknyttede loggbøker og E2E-krypterte data ugjenkallelig?",
|
||||
"delete_account_confirm_yes": "Ja, slett konto og alle data",
|
||||
"delete_account_confirm_no": "Avbryt",
|
||||
"delete_account_failed": "Kontoen kunne ikke slettes. Vennligst prøv igjen.",
|
||||
"delete_backup_hint": "Tips: Lag sikkerhetskopier av loggbøkene dine (.daagbok.json) i innstillingene for hver loggbok før du sletter dem.",
|
||||
"deleting_account": "Kontoen vil bli slettet...",
|
||||
"invite_push_prompt_title": "Aktivere push-varsler?",
|
||||
"invite_push_prompt_message": "Så snart inviterte besetningsmedlemmer synkroniserer endringer, kan du bli informert via push. Ingen loggbokinnhold sendes i ren tekst.",
|
||||
"invite_push_prompt_ios_message": "Så snart besetningsmedlemmene synkroniserer endringer, kan du bli informert via push. På iPhone/iPad: Legg til appen på startskjermen (iOS 16.4+), og aktiver deretter push i brukerprofilen.",
|
||||
"invite_push_prompt_enable": "Aktiver nå",
|
||||
"invite_push_prompt_later": "Senere",
|
||||
"invite_push_prompt_success": "Push-varsler er aktive på denne enheten.",
|
||||
"backup_title": "Sikkerhetskopiering og gjenoppretting",
|
||||
"backup_desc": "Fullstendig kryptert sikkerhetskopi av denne loggboken (oppføringer, bilder, GPS-spor, mannskap, skip). Beskyttet med sikkerhetskopieringspassord - for gjenoppretting til denne eller en ny konto.",
|
||||
"backup_export_title": "Opprett sikkerhetskopi",
|
||||
"backup_export_desc": "Laster ned alle lokale data som .daagbok.json. Hold filen og passordfrasen adskilt og sikker.",
|
||||
"backup_restore_title": "Gjenopprett sikkerhetskopi",
|
||||
"backup_restore_desc": "Gjenoppretter en sikkerhetskopi til din nåværende konto - selv etter at du har registrert en ny konto.",
|
||||
"backup_passphrase": "Passord for sikkerhetskopiering",
|
||||
"backup_passphrase_placeholder": "Minst 8 tegn",
|
||||
"backup_passphrase_confirm": "Bekreft passordfrasen",
|
||||
"backup_passphrase_short": "Passordfrasen for sikkerhetskopiering må være på minst 8 tegn.",
|
||||
"backup_passphrase_mismatch": "Passordfraser stemmer ikke overens.",
|
||||
"backup_wrong_passphrase": "Passordfrasen er feil eller sikkerhetskopien er ødelagt.",
|
||||
"backup_export_btn": "Last ned sikkerhetskopi",
|
||||
"backup_exporting": "Sikkerhetskopien er opprettet...",
|
||||
"backup_export_success": "Sikkerhetskopi opprettet ({{count}} reisedager).",
|
||||
"backup_file_label": "Sikkerhetskopifil (.daagbok.json)",
|
||||
"backup_preview_btn": "Sjekk innhold",
|
||||
"backup_previewing": "Sjekk...",
|
||||
"backup_restore_btn": "Gjenopprett",
|
||||
"backup_restoring": "Vil bli restaurert...",
|
||||
"backup_restore_success": "Loggbok \"{{title}}\" er gjenopprettet.",
|
||||
"backup_restore_cancelled": "Gjenoppretting avlyst.",
|
||||
"backup_invalid_json": "Filen er ikke en gyldig JSON-fil.",
|
||||
"backup_invalid_format": "Ukjent eller utdatert sikkerhetskopiformat.",
|
||||
"backup_not_owner": "Det er bare eieren av loggboken som kan opprette sikkerhetskopier.",
|
||||
"backup_not_authenticated": "Vennligst logg inn for å gjenopprette en sikkerhetskopi.",
|
||||
"backup_id_conflict": "Det finnes allerede en loggbok med denne ID-en.",
|
||||
"backup_overwrite_confirm": "Den eksisterende loggboken med samme ID erstattes. Fortsette?",
|
||||
"backup_new_id_confirm": "Importere sikkerhetskopien som en ny loggbok med ny ID?",
|
||||
"backup_stat_entries": "{{count}} Reisedager",
|
||||
"backup_stat_photos": "{{count}} Bilder",
|
||||
"backup_stat_crew": "{{count}} Mannskapsposter",
|
||||
"backup_stat_tracks": "{{count}} GPS-spor",
|
||||
"backup_exported_at": "Eksportert: {{date}}"
|
||||
},
|
||||
"disclaimer": {
|
||||
"title": "Viktige merknader",
|
||||
"intro": "Vennligst les følgende instruksjoner før du bruker Kapteins Daagbok.",
|
||||
"e2e_title": "Ende-til-ende-kryptering",
|
||||
"e2e_body": "Loggbokdataene dine er kryptert fra ende til ende. Bare du - eller personer med din nøkkel - kan lese innholdet. Kun krypterte data lagres på serveren.",
|
||||
"pwa_title": "Progressiv webapp (PWA)",
|
||||
"pwa_body": "Kapteins Daagbok kjører som en progressiv webapp i nettleseren din og kan installeres på enheten din - på samme måte som en native-app, men uten en appbutikk.",
|
||||
"storage_title": "Lokal lagring og synkronisering",
|
||||
"storage_body": "Dataene dine lagres lokalt på enheten din (IndexedDB). Endringer synkroniseres med serveren når en Internett-tilkobling er aktiv. Du kan fortsette å jobbe uten tilkobling, synkroniseringen skjer senere.",
|
||||
"free_title": "Gratis og reklamefri",
|
||||
"free_body": "Kapteins Daagbok er gratis og inneholder ingen reklame.",
|
||||
"liability_title": "Ansvarsfraskrivelse",
|
||||
"liability_body": "Bruk av appen skjer på eget ansvar. Vi fraskriver oss ethvert ansvar for skader som oppstår som følge av bruk av appen - inkludert feilaktige eller ufullstendige loggbokoppføringer, tap av data eller tekniske feil.",
|
||||
"warranty_title": "Ingen garanti",
|
||||
"warranty_body": "Det gis ingen garanti for tjenestens funksjon, korrekthet eller tilgjengelighet. Driften kan når som helst bli avbrutt, begrenset eller kansellert.",
|
||||
"copyright": "© 2026 KnorrLabs, Markus F.J. Busche",
|
||||
"accept": "Godta og fortsett",
|
||||
"close": "Lukk",
|
||||
"button_title": "Merknader og ansvarsfraskrivelse"
|
||||
},
|
||||
"feedback": {
|
||||
"button_title": "Send tilbakemelding",
|
||||
"title": "Tilbakemeldinger",
|
||||
"intro": "Del feil, ideer eller generelle tilbakemeldinger. Meldingen din vil bli sendt til prosjektteamet via en sikker varslingskanal.",
|
||||
"category_label": "Kategori",
|
||||
"category_general": "Generelt",
|
||||
"category_bug": "Rapporter feil",
|
||||
"category_feature": "Forespørsel om funksjonalitet",
|
||||
"category_translation": "Oversettelsesfeil",
|
||||
"contact_label": "E-post (valgfritt)",
|
||||
"contact_placeholder": "deine@email.beispiel",
|
||||
"message_label": "Melding",
|
||||
"message_placeholder": "Beskriv tilbakemeldingene dine...",
|
||||
"send": "Send",
|
||||
"sending": "Vil bli sendt...",
|
||||
"cancel": "Avbryt",
|
||||
"success": "Tusen takk skal du ha! Tilbakemeldingen din er sendt.",
|
||||
"error_send": "Tilbakemelding kunne ikke sendes. Vennligst prøv igjen senere.",
|
||||
"error_invalid_email": "Vennligst skriv inn en gyldig e-postadresse.",
|
||||
"error_not_configured": "Tilbakemelding er ikke tilgjengelig på denne serveren.",
|
||||
"error_rate_limited": "For mange tilbakemeldinger på kort tid. Vennligst vent noen minutter.",
|
||||
"error_spam": "Denne meldingen kunne ikke sendes. Vennligst omformuler den."
|
||||
},
|
||||
"demo": {
|
||||
"logbook_title": "Demologgbok Østersjøen",
|
||||
"badge": "Demo",
|
||||
"public_banner": "Skrivebeskyttet demovisning",
|
||||
"cta_register": "Opprett konto",
|
||||
"back_to_login": "Til registreringen"
|
||||
},
|
||||
"invitation": {
|
||||
"error_invalid_key": "Invitasjonslenken er kryptografisk ugyldig (feil nøkkel).",
|
||||
"error_missing_key": "Invitasjonslenken inneholder ikke en dekrypteringsnøkkel (#key=...). Vennligst bruk den fullstendige lenken fra eieren.",
|
||||
"error_expired": "Denne invitasjonen har utløpt (gyldig i 48 timer).",
|
||||
"error_invalid_token": "Invitasjonstokenet er ugyldig.",
|
||||
"error_load_failed": "Invitasjonsdetaljer kunne ikke lastes inn.",
|
||||
"error_incomplete_session": "Økten er ufullstendig - vennligst logg inn på nytt (bruker-ID mangler).",
|
||||
"error_accept_failed": "Tiltredelse mislyktes.",
|
||||
"error_login_failed": "Passkey Innlogging mislyktes.",
|
||||
"error_username_missing": "Brukernavnet ble ikke funnet - vennligst logg inn på nytt.",
|
||||
"error_register_failed": "Registrering mislyktes.",
|
||||
"loading_joining": "Bli med...",
|
||||
"loading_checking": "Invitasjonen vil bli sjekket...",
|
||||
"loading_unlocking": "Loggboken er låst opp og synkronisert...",
|
||||
"loading_retrieving_key": "Last ned krypteringsnøkkelen...",
|
||||
"error_title": "Feil i invitasjonen",
|
||||
"back_to_start": "Tilbake til start",
|
||||
"title": "Invitasjon til loggbok",
|
||||
"invited_by": "Invitasjon fra",
|
||||
"vessel_logbook": "Skip / Loggbok",
|
||||
"signed_in_preparing": "Registrert som {{username}}. Tilslutning er under forberedelse...",
|
||||
"join_again": "Bli med igjen",
|
||||
"login_or_register_hint": "Logg inn eller registrer en konto for å bli med i loggboken.",
|
||||
"or_sign_up": "ELLER REGISTRER DEG PÅ NYTT",
|
||||
"register_crew_account": "Opprett en ny crew-konto",
|
||||
"username_label": "Brukernavn",
|
||||
"create_passkey": "Opprett Passkey",
|
||||
"switch_language_en": "Engelsk",
|
||||
"switch_language_de": "Tysk"
|
||||
},
|
||||
"stats": {
|
||||
"title": "Statistikk",
|
||||
"subtitle": "Oversikt over ruter, forbruk og kjøretype",
|
||||
"scope_label": "Evalueringsområde",
|
||||
"scope_logbook": "Denne loggboken",
|
||||
"scope_account": "Alle loggbøker",
|
||||
"loading": "Statistikken er beregnet...",
|
||||
"no_data": "Ingen reisedager tilgjengelig ennå.",
|
||||
"total_distance": "Total avstand",
|
||||
"travel_days": "Reisedager",
|
||||
"sail_distance": "Under seil",
|
||||
"motor_distance": "Maskinreise",
|
||||
"motor_hours_total": "Totalt antall maskintimer",
|
||||
"daily_motor_hours": "Maskintimer per reisedøgn",
|
||||
"avg_motor_hours": "Ø maskintimer per reisedøgn",
|
||||
"unknown_propulsion": "Ukjent",
|
||||
"fuel_total": "Totalt drivstoff",
|
||||
"water_total": "Totalt vann",
|
||||
"daily_etmal": "Daglige tider",
|
||||
"daily_consumption": "Daglig forbruk",
|
||||
"route_overview": "Rute",
|
||||
"route_map_title": "Oversikt over ruten",
|
||||
"propulsion_title": "Seil vs. maskin",
|
||||
"propulsion_hint": "Fordelingen er basert på loggbokhendelser per reisedag, ikke på GPS-segmenter.",
|
||||
"avg_distance": "Ø per reisedag",
|
||||
"avg_fuel": "Ø Drivstoff",
|
||||
"avg_water": "Ø Vann",
|
||||
"fuel_per_nm": "Drivstoff per sm",
|
||||
"fuel_per_motor_hour": "Drivstoff per maskintime",
|
||||
"daily_fuel_per_motor_hour": "Drivstofforbruk per maskintime per kjøredag",
|
||||
"fuel_legend": "Drivstoff",
|
||||
"water_legend": "Vann",
|
||||
"unit_nm": "sm",
|
||||
"unit_h": "h",
|
||||
"unit_l": "L",
|
||||
"day_label": "Dag {{day}}",
|
||||
"account_logbooks": "Oversikt over loggbøker",
|
||||
"col_logbook": "Loggbok",
|
||||
"event_series_title": "Hendelsesforløp",
|
||||
"event_series_hint": "Kronologiske verdier fra hendelsesloggen.",
|
||||
"event_series_pressure": "Lufttrykk",
|
||||
"event_series_wind": "Vind",
|
||||
"event_series_motor": "Motor",
|
||||
"event_series_empty": "Ingen oppføringer ennå."
|
||||
},
|
||||
"tour": {
|
||||
"skip": "Hopp over turen",
|
||||
"back": "Tilbake",
|
||||
"next": "Videre",
|
||||
"finish": "Ferdig",
|
||||
"progress": "Trinn {{current}} fra {{total}}",
|
||||
"steps": {
|
||||
"welcome": {
|
||||
"title": "Velkommen om bord!",
|
||||
"body": "Vi har laget en demo-loggbok med tre dagers reise i Kielfjorden for deg. Du kan når som helst slette eksempeloppføringene hvis du vil starte din egen loggbok. Denne korte omvisningen viser deg de viktigste funksjonene."
|
||||
},
|
||||
"welcome_public": {
|
||||
"title": "Velkommen om bord!",
|
||||
"body": "Utforsk vår demologgbok med tre dagers reise i Kielfjorden - uten konto. Denne korte omvisningen viser deg skipsdata, mannskap og loggbokoppføringer."
|
||||
},
|
||||
"nav_logs": {
|
||||
"title": "Loggbokoppføringer",
|
||||
"body": "Her administrerer du reisedagene dine - avreise, destinasjon, vær, drivstoffnivå og GPS-spor."
|
||||
},
|
||||
"entry_list": {
|
||||
"title": "Dine reisedager",
|
||||
"body": "Hvert kort representerer en reisedag. Trykk på en oppføring for å vise eller redigere detaljer."
|
||||
},
|
||||
"entry_open": {
|
||||
"title": "Åpen reisedag",
|
||||
"body": "Slik ser en fullført loggbok ut - med hendelser, tanknivåer og mer."
|
||||
},
|
||||
"entry_track": {
|
||||
"title": "GPS-sporing",
|
||||
"body": "Last opp GPX-filer eller se allerede lagrede ruter på kartet - inkludert avstand og hastighet."
|
||||
},
|
||||
"nav_vessel": {
|
||||
"title": "Skipsdata",
|
||||
"body": "Skriv inn navn, dimensjoner og tekniske data for båten din - fyll inn én gang, tilgjengelig for alle reisedager."
|
||||
},
|
||||
"nav_crew": {
|
||||
"title": "Mannskapsliste",
|
||||
"body": "Administrer mannskapet og tilordne dem til reisedager senere."
|
||||
},
|
||||
"nav_stats": {
|
||||
"title": "Dashbord for statistikk",
|
||||
"body": "Her kan du se kjørelengder, drivstofforbruk, rutekart og kjøreandeler - automatisk beregnet ut fra loggbokoppføringene dine."
|
||||
},
|
||||
"nav_feedback": {
|
||||
"title": "Send tilbakemelding",
|
||||
"body": "Du kan bruke dette skjemaet til å sende feil, ideer eller generelle tilbakemeldinger direkte til prosjektteamet - også etter omvisningen, når som helst ved hjelp av ikonet øverst til høyre."
|
||||
},
|
||||
"nav_profile": {
|
||||
"title": "Din brukerprofil",
|
||||
"body": "Du får tilgang til din personlige profil via skipperknappen øverst - uavhengig av hvilken loggbok du bruker."
|
||||
},
|
||||
"profile_preferences": {
|
||||
"title": "Regnskap og presentasjon",
|
||||
"body": "Her kan du administrere kontoidentitet, tema og lys/mørk modus. Du kan når som helst starte appturen på nytt. Passkeys og sikkerhetsinnstillinger finner du lenger ned i profilen."
|
||||
},
|
||||
"finish": {
|
||||
"title": "Greit!",
|
||||
"body": "Du kommer rett til statistikkoversikten. Du kan når som helst starte turen på nytt i brukerprofilen din. Ha en riktig god tur!"
|
||||
}
|
||||
}
|
||||
},
|
||||
"seo": {
|
||||
"title": "Kapteins Daagbok - Gratis digital loggbok for fritidsbåter (uten reklame)",
|
||||
"description": "Gratis, annonsefri digital loggbok med ende-til-ende-kryptering og Passkey-pålogging. Dokumenter seilingsdager, GPS-spor, mannskaps- og skipsdata på en sikker måte - også offline som PWA.",
|
||||
"keywords": "Yachtloggbok, skipsloggbok, loggbok om bord, seiling, Passkey, E2E-kryptering, GPS-sporing, maritim loggbok, gratis, reklamefri, gratis, uten reklame",
|
||||
"ogImageAlt": "Kapteins Daagbok Logo"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,887 @@
|
||||
{
|
||||
"translation": {
|
||||
"app": {
|
||||
"name": "Kapteins Daagbok",
|
||||
"tagline": "Loggbok för privat yacht",
|
||||
"beta": "Beta",
|
||||
"beta_hint": "Betaversion - funktioner kan fortfarande ändras"
|
||||
},
|
||||
"languages": {
|
||||
"de": "Deutsch",
|
||||
"en": "English",
|
||||
"da": "Dansk",
|
||||
"sv": "Svenska",
|
||||
"nb": "Norsk"
|
||||
},
|
||||
"common": {
|
||||
"unsaved_changes_title": "Osparade ändringar",
|
||||
"unsaved_changes_message": "Du har ändringar som inte sparats. Vill du verkligen lämna sidan? Dina ändringar kommer att gå förlorade.",
|
||||
"unsaved_changes_leave": "Övergivande",
|
||||
"unsaved_changes_stay": "Stanna kvar"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Instrumentpanel",
|
||||
"vessel": "Fartygsdata",
|
||||
"crew": "Besättningslista",
|
||||
"deviation": "Distraktionsbord",
|
||||
"logs": "Loggboksanteckningar",
|
||||
"stats": "Statistik",
|
||||
"settings": "Inställningar"
|
||||
},
|
||||
"auth": {
|
||||
"welcome": "Välkommen till Kapteins Daagbok",
|
||||
"tagline": "Din säkra, E2Ekrypterade loggbok för sjöfarten.",
|
||||
"register": "Registrera dig med Passkey",
|
||||
"login": "Logga in med Passkey",
|
||||
"login_as": "Logga in som {{name}}",
|
||||
"quick_login": "Snabb inloggning",
|
||||
"forget_account": "Glömt konto på den här enheten",
|
||||
"not_user": "Inte {{name}}?",
|
||||
"recovery_title": "Din återställningsnyckel",
|
||||
"recovery_warning": "VIKTIGT: Skriv ner dessa 12 ord. Om du förlorar din Passkey och dessa ord kan dina data inte återställas.",
|
||||
"confirm_recovery": "Jag har skrivit ner orden",
|
||||
"status_logged_in": "Inloggad",
|
||||
"status_logged_out": "Avbruten",
|
||||
"copied": "Kopierat!",
|
||||
"copy_phrase": "Kopiera tangent",
|
||||
"enter_recovery": "Ange återställningsnyckel",
|
||||
"recovery_fallback_warning": "Din Passkey har autentiserats, men din enhet stöder inte maskinvarubaserad nyckelavledning. Ange din återställningsnyckel på 12 ord för att dekryptera din loggbok.",
|
||||
"recovery_placeholder": "Ange din återställningsnyckel som består av 12 ord åtskilda av mellanslag...",
|
||||
"back": "Tillbaka",
|
||||
"decrypting": "Dekryptering...",
|
||||
"decrypt_logbook": "Dekryptera loggbok",
|
||||
"error_incorrect_recovery": "Felaktig återställningsnyckel. Dekryptering misslyckades.",
|
||||
"error_decryption_failed": "Dekrypteringen misslyckades. Vänligen kontrollera din återställningsnyckel.",
|
||||
"or_register": "eller registrera dig",
|
||||
"explore_demo": "Utforska demoversionen utan konto",
|
||||
"username_placeholder": "Användarnamn / Skepparnamn",
|
||||
"processing": "Bearbetning...",
|
||||
"help": "Hjälp",
|
||||
"setup_pin_title": "Ange lokal PIN-kod (tillval)",
|
||||
"setup_pin_warning": "Eftersom din enhet inte stöder direkt härledning av Passkey-nycklar måste du annars ange din nyckel på 12 ord varje gång du loggar in på den här enheten. Konfigurera en lokal PIN-kod för att undvika detta.",
|
||||
"pin_placeholder": "E.G. 123456",
|
||||
"pin_label": "Lokal PIN-kod (4-8 siffror)",
|
||||
"save_pin": "Spara PIN-kod och fortsätt",
|
||||
"skip_pin": "Skip & använd återvinning",
|
||||
"enter_pin_title": "Dekryptera med PIN-kod",
|
||||
"enter_pin_warning": "Ange din lokala PIN-kod för att låsa upp dekrypteringsnyckeln på den här enheten.",
|
||||
"enter_pin_placeholder": "Ange din PIN-kod...",
|
||||
"decrypt_with_pin": "Dekryptera",
|
||||
"use_recovery_instead": "Använd återställningsnycklar istället",
|
||||
"error_incorrect_pin": "Felaktig PIN-kod. Dekryptering misslyckades.",
|
||||
"error_invalid_host": "Passkeys fungerar inte via 127.0.0.1. Öppna appen via localhost.",
|
||||
"use_localhost_link": "Byt till localhost",
|
||||
"error_passkey_cancelled": "Passkey-inloggning avbröts eller gick ut. Försök igen.",
|
||||
"error_invalid_rp_id": "Passkey-domänen matchar inte (RP ID). Använd http://localhost:5173 med RP_ID=localhost i .env för lokal utveckling.",
|
||||
"error_session_incomplete": "Inloggning ofullständig. Logga in med passkey igen."
|
||||
},
|
||||
"pwa": {
|
||||
"title": "Installera app",
|
||||
"generic_benefit": "Installera Kapteins Daagbok på din enhet för snabbare åtkomst, offline-användning och permanent datalagring.",
|
||||
"ios_instructions": "På iPad/iPhone: Lägg till appen på startskärmen så att dina loggboksdata förblir skyddade och appen startar som en inbyggd app.",
|
||||
"ios_step_share": "Tryck på aktiesymbolen i fältet Safari.",
|
||||
"ios_step_add": "Välj \"Gå till startskärmen\"",
|
||||
"install_now": "Installera nu",
|
||||
"installing": "Installation...",
|
||||
"later": "Senare",
|
||||
"never": "Visa inte mer",
|
||||
"platform_ios": "Installation via Safari.",
|
||||
"platform_android": "Installation via webbläsaren",
|
||||
"platform_desktop": "Installation som en skrivbordsapp",
|
||||
"settings_section": "Installation av app",
|
||||
"update_title": "Uppdatering tillgänglig",
|
||||
"update_desc": "En ny version av Kapteins Daagbok är klar. Uppdatera för att få de senaste ändringarna.",
|
||||
"update_now": "Uppdatering nu",
|
||||
"update_reloading": "Laddar..."
|
||||
},
|
||||
"sync": {
|
||||
"status_synced": "Synkroniserad",
|
||||
"status_syncing": "Synkronisera...",
|
||||
"status_offline": "Offline-cache",
|
||||
"status_unsynced": "Osynkroniserade förändringar"
|
||||
},
|
||||
"vessel": {
|
||||
"title": "Masterdata för fartyg",
|
||||
"name": "Yacht namn",
|
||||
"type": "Typ av båt",
|
||||
"type_unset": "- inte specificerad -",
|
||||
"type_sailing": "Segelyacht",
|
||||
"type_motor": "Motorbåt",
|
||||
"length_m": "Längd (m)",
|
||||
"draft_m": "Djupgående (m)",
|
||||
"air_draft_m": "Höjd (m)",
|
||||
"invalid_metric": "Ogiltigt numeriskt värde - ange meter som ett decimaltal (t.ex. 12,5).",
|
||||
"port": "Hem hamn",
|
||||
"owner": "Ägare",
|
||||
"charter": "Charterbolag",
|
||||
"registration": "Registreringsnummer/registreringsskylt",
|
||||
"callsign": "Radioanropssignal",
|
||||
"atis": "ATIS nr.",
|
||||
"mmsi": "MMSI nr.",
|
||||
"save": "Spara fartygsdata",
|
||||
"saving": "Kommer att sparas...",
|
||||
"saved": "Fartygsdata har sparats framgångsrikt!",
|
||||
"loading": "Fartygsdata är inlästa...",
|
||||
"sails_list": "Segel (befintliga segel)",
|
||||
"sails_help": "Ange här de segel som finns tillgängliga på din båt (t.ex. storsegel, genua, fock).",
|
||||
"add_sail": "Lägg till segel",
|
||||
"sail_name_placeholder": "z. t.ex. storsegel",
|
||||
"no_sails": "Inga segel lagrade.",
|
||||
"photo_add": "Lägg till foto",
|
||||
"photo_change": "Ändra foto",
|
||||
"photo_delete": "Ta bort foto",
|
||||
"tanks_section": "Tankar (kapacitet)",
|
||||
"tanks_help": "Valfritt i liter - möjliggör slider i journalen för kända tankstorlekar.",
|
||||
"freshwater_capacity_l": "Dricksvatten (liter)",
|
||||
"fuel_capacity_l": "Bränsle (liter)",
|
||||
"greywater_capacity_l": "Gråvatten (liter)",
|
||||
"invalid_tank_liters": "Ogiltigt numeriskt värde - ange liter som ett tal (t.ex. 200)."
|
||||
},
|
||||
"logs": {
|
||||
"title": "Loggboksjournal",
|
||||
"new_entry": "Ny resdag",
|
||||
"travel_details": "Detaljer om resan",
|
||||
"add_event": "Lägg till ny loggbokspost",
|
||||
"add_event_btn": "Lägg till händelse",
|
||||
"edit_event": "Redigera händelse",
|
||||
"save_event_btn": "Spara ändring",
|
||||
"cancel_event_edit": "Avbryt",
|
||||
"delete_event": "Ta bort händelse",
|
||||
"sign_cleared_skipper_re_sign_title": "Skippers signatur borttagen",
|
||||
"sign_cleared_skipper_re_sign": "Händelseloggen har ändrats. Skepparens signatur har tagits bort. Vänligen godkänn igen.",
|
||||
"date": "datum",
|
||||
"day_of_travel": "Resedag / resedag",
|
||||
"departure": "Starthamn (resa från)",
|
||||
"destination": "Destinationsport (till)",
|
||||
"route": "Resa från/till",
|
||||
"freshwater": "Färskvatten (liter)",
|
||||
"fuel": "Treibstoff / Bränsle (liter)",
|
||||
"greywater": "Gråvatten (liter)",
|
||||
"greywater_level": "Fyllnadsnivå",
|
||||
"tank_slider_of_max": "{{current}} / {{max}} L",
|
||||
"tank_capacity_tooltip": "Om tankens kapacitet (liter) finns lagrad i fartygets data kan du ange fyllnadsnivåerna här med hjälp av skjutreglaget.",
|
||||
"morning": "Stå på morgonen",
|
||||
"refilled": "Påfylld",
|
||||
"evening": "Kvällsställ",
|
||||
"consumption": "Daglig konsumtion",
|
||||
"signatures": "Underskrifter / frisläppande",
|
||||
"sign_skipper": "Skepparens signatur",
|
||||
"sign_crew": "Besättningens signatur",
|
||||
"sign_hint": "Signera med finger, penna eller mus",
|
||||
"sign_clear": "Radera",
|
||||
"sign_export_image": "[Signatur]",
|
||||
"sign_with_passkey": "Frigör med Passkey",
|
||||
"sign_passkey_signing": "Passkey begärs...",
|
||||
"sign_passkey_signed": "Utgiven av {{username}}",
|
||||
"sign_passkey_export": "Passkey: {{username}} ({{date}})",
|
||||
"sign_attribution_export": "{{username}} ({{date}})",
|
||||
"sign_passkey_clear": "Ta bort Passkey release",
|
||||
"sign_mode_passkey": "Passkey",
|
||||
"sign_mode_classic": "Klassisk",
|
||||
"sign_passkey_failed": "Passkey Frigöring misslyckades",
|
||||
"sign_passkey_cancelled": "Passkey Frigörandet inställt",
|
||||
"sign_invalid": "Signaturen är ogiltig - innehållet har ändrats",
|
||||
"sign_badge_skipper": "Skeppare",
|
||||
"sign_badge_skipper_invalid": "Ogiltig",
|
||||
"sign_badge_skipper_title_valid": "Skepparen har släppt",
|
||||
"sign_badge_skipper_title_invalid": "Skippers signatur ogiltig - innehållet har ändrats",
|
||||
"sign_classic_or_passkey": "Valfritt: klassisk signatur eller Passkey release ovan",
|
||||
"sign_crew_passkey_hint": "Besättningsmedlemmar med skrivbehörighet kan frigöra via Passkey.",
|
||||
"sign_offline_hint": "Passkey-Godkännande kräver Internet - klassisk signatur möjlig offline",
|
||||
"sign_lock_notice": "Efter undertecknandet är det inte möjligt att göra ändringar i loggboksanteckningen (utom foton) utan att skepparen och besättningen måste underteckna på nytt.",
|
||||
"sign_lock_active": "Denna post är signerad. Ändringar i loggboken (utom foton) tar automatiskt bort skepparens och besättningens signaturer.",
|
||||
"sign_lock_warning_title": "Bekräfta underskrift",
|
||||
"sign_lock_warning": "Efter undertecknandet är det inte längre möjligt att göra ändringar i loggboksanteckningen (utom foton) utan att skepparen och besättningen måste underteckna på nytt.\n\nVill du fortsätta?",
|
||||
"sign_proceed": "Teckna",
|
||||
"sign_cancel": "Avbryt",
|
||||
"sign_cleared_re_sign_title": "Underskrifter borttagna",
|
||||
"sign_cleared_re_sign": "Loggboksanteckningen har ändrats. Skepparens och besättningens namnteckningar har tagits bort. Vänligen underteckna igen.",
|
||||
"no_entries": "Inga loggboksposter hittade för denna yacht. Skapa din första resedag!",
|
||||
"back_to_list": "Tillbaka till tidskriftslistan",
|
||||
"save": "Spara loggbokssida",
|
||||
"saving": "Kommer att sparas...",
|
||||
"saved": "Loggbokssidan har sparats framgångsrikt!",
|
||||
"loading": "Journalen laddas...",
|
||||
"view_mode_label": "Vy",
|
||||
"view_list": "Lista",
|
||||
"live_mode": "Live",
|
||||
"live_title": "Live-journal",
|
||||
"live_loading": "Live-journal laddas...",
|
||||
"live_retry": "Försök igen",
|
||||
"live_load_error": "Live-journal kunde inte laddas.",
|
||||
"live_action_error": "Posten kunde inte sparas.",
|
||||
"live_open_editor": "Fullständig editor",
|
||||
"live_actions_label": "Snabbåtgärder",
|
||||
"live_stream_label": "Händelselogg",
|
||||
"live_stream_title": "Journal",
|
||||
"live_no_events": "Inga poster ännu — tryck på en åtgärd.",
|
||||
"live_motor_start": "Motor Start",
|
||||
"live_motor_stop": "Motor Stopp",
|
||||
"live_cast_off": "Avgång",
|
||||
"live_moor": "Anlöp",
|
||||
"live_sails_btn": "Segel",
|
||||
"live_sails_pick": "Välj segel",
|
||||
"live_sails_pick_hint": "Tryck på flera segel (tryck igen för att avmarkera), logga sedan.",
|
||||
"live_sails_selected": "Valt: {{sails}}",
|
||||
"live_sails_confirm": "Logga",
|
||||
"live_sails_confirm_count": "Logga ({{count}})",
|
||||
"live_sails": "Segel: {{sails}}",
|
||||
"live_fix": "Fix",
|
||||
"live_fix_coords": "Fix {{lat}}, {{lng}}",
|
||||
"live_fix_manual_hint": "GPS ej tillgänglig. Ange latitud och longitud manuellt, eller försök igen med GPS-knappen.",
|
||||
"live_fix_gps_loading": "Hämtar GPS-position…",
|
||||
"live_fix_invalid": "Ange giltiga koordinater (latitud −90…90, longitud −180…180).",
|
||||
"live_fix_lat_placeholder": "Latitud (Lat)",
|
||||
"live_fix_lng_placeholder": "Longitud (Lng)",
|
||||
"live_photo_btn": "Foto (kamera)",
|
||||
"live_photo_capture_btn": "Ta foto",
|
||||
"live_photo_save_btn": "Spara",
|
||||
"live_photo_retake_btn": "Ta om",
|
||||
"live_photo_capture_failed": "Bildtagning misslyckades. Försök igen.",
|
||||
"live_photo_open_camera_btn": "Öppna kamera",
|
||||
"live_photo_native_hint": "Ta ett foto med enhetens kamera och spara det här efteråt.",
|
||||
"live_photo_camera_starting": "Startar kamera…",
|
||||
"live_photo_camera_denied": "Kameraåtkomst nekad eller ej tillgänglig.",
|
||||
"live_photo_camera_unavailable": "Kameran stöds inte i den här webbläsaren.",
|
||||
"live_photo_error": "Foto kunde inte sparas.",
|
||||
"live_photo_entry": "Foto: {{caption}}",
|
||||
"live_photo_entry_plain": "Foto taget",
|
||||
"live_undo_photo_hint": "Foto sparat",
|
||||
"live_comment_btn": "Kommentar",
|
||||
"live_comment_placeholder": "Ange text…",
|
||||
"live_comment_confirm": "Logga",
|
||||
"live_gps_error": "GPS-position kunde inte bestämmas.",
|
||||
"live_event_generic": "Händelse",
|
||||
"live_weather_btn": "Väder",
|
||||
"live_weather_owm_btn": "Hämta OpenWeatherMap-väder",
|
||||
"live_weather_owm_loading": "Hämtar väder…",
|
||||
"live_weather_fix_required": "Logga först en GPS-fix (Fix-knappen) för att hämta OpenWeatherMap-väder. Positionen får högst vara 6 timmar gammal.",
|
||||
"live_weather_fix_stale": "Senaste GPS-fixen är äldre än 6 timmar. Logga en ny fix innan du hämtar väder.",
|
||||
"live_wind_btn": "Vind",
|
||||
"live_temp_btn": "T °C",
|
||||
"live_pressure_btn": "Lufttryck",
|
||||
"live_precip_btn": "Nederbörd",
|
||||
"live_sea_state_btn": "Sjögang",
|
||||
"live_course_btn": "Kurs",
|
||||
"live_fuel_btn": "Diesel",
|
||||
"live_water_btn": "Vatten",
|
||||
"live_wind_entry": "Vind {{value}}",
|
||||
"live_temp_entry": "Temperatur {{temp}} °C",
|
||||
"live_pressure_entry": "Lufttryck {{value}} hPa",
|
||||
"live_precip_entry": "Nederbörd {{value}}",
|
||||
"live_sea_state_entry": "Sjögang {{value}}",
|
||||
"live_course_entry": "Kurs {{course}}",
|
||||
"live_fuel_entry": "Diesel +{{liters}} L",
|
||||
"live_water_entry": "Vatten +{{liters}} L",
|
||||
"live_auto_position": "Auto-position",
|
||||
"live_undo_hint": "Post sparad",
|
||||
"live_undo_btn": "Ångra",
|
||||
"live_pressure_placeholder": "t.ex. 1013",
|
||||
"live_temp_placeholder": "t.ex. 18",
|
||||
"live_precip_placeholder": "t.ex. lätt regn",
|
||||
"live_sea_state_placeholder": "t.ex. 3",
|
||||
"live_course_placeholder": "t.ex. 245",
|
||||
"live_fuel_placeholder": "Påfyllda liter",
|
||||
"live_water_placeholder": "Påfyllda liter",
|
||||
"live_sog_btn": "SOG",
|
||||
"live_stw_btn": "STW",
|
||||
"live_sog_entry": "SOG {{speed}} kn",
|
||||
"live_stw_entry": "STW {{speed}} kn",
|
||||
"live_sog_placeholder": "t.ex. 5,2",
|
||||
"live_stw_placeholder": "t.ex. 4,8",
|
||||
"live_sog_hint": "Fart över grund (kn) — GPS-värde fylls i om tillgängligt.",
|
||||
"delete_entry": "Ta bort tagg",
|
||||
"delete_confirm": "Är du säker på att du vill radera den här resedagen permanent?",
|
||||
"carry_over_tanks_title": "Överföra data från föregående dag?",
|
||||
"carry_over_tanks_confirm": "Ta över starthamn, färskvatten, bränsle och gråvatten från startnivåerna från resans sista dag?\n\nStarthamn: {{departure}}\nFärskvatten: {{fw}} L\nBränsle: {{fuel}} L\nGråvatten: {{greywater}} L",
|
||||
"carry_over_tanks_yes": "Ta över",
|
||||
"carry_over_tanks_no": "Börja med 0",
|
||||
"event_title": "Kronologisk händelselogg",
|
||||
"no_events": "Inga händelser inlagda för denna resdag ännu.",
|
||||
"event_time": "Tid på dygnet",
|
||||
"event_mgk": "MgK-kurs",
|
||||
"event_rwk": "RwK-kurs",
|
||||
"event_course_section": "Kurs",
|
||||
"course_dial_hint": "Vrid ringen eller gå in i grader",
|
||||
"course_dial_step_label": "Stegstorlek",
|
||||
"course_step_fine": "1°",
|
||||
"course_step_medium": "5°",
|
||||
"course_step_coarse": "10°",
|
||||
"course_tab_mgk": "MgK",
|
||||
"course_tab_rwk": "rwK",
|
||||
"course_invalid": "Ogiltig kurs (0-360)",
|
||||
"course_placeholder_degrees": "z. B. 180",
|
||||
"course_placeholder_cardinal": "z. E.G. NW",
|
||||
"compass_n": "N",
|
||||
"compass_e": "O",
|
||||
"compass_s": "S",
|
||||
"compass_w": "W",
|
||||
"wind_mode_cardinal": "Kardinal",
|
||||
"wind_mode_degrees": "Som examen",
|
||||
"event_wind_direction": "Vindriktning",
|
||||
"event_wind_strength": "Vindstyrka",
|
||||
"event_sea_state": "Havets tillstånd",
|
||||
"event_weather": "Väder",
|
||||
"event_log": "Log (sm)",
|
||||
"event_gps": "GPS-position",
|
||||
"event_location": "Plats / hamn",
|
||||
"event_location_placeholder": "z. t.ex. Kiel",
|
||||
"event_remarks": "Anmärkningar / incidenter",
|
||||
"gps_btn": "Hämta GPS-koordinater",
|
||||
"weather_btn": "OpenWeatherMap Ring upp väder",
|
||||
"event_wind_pressure": "Lufttryck (hPa)",
|
||||
"event_heel": "Krängning (°)",
|
||||
"event_sails": "Segelhantering / motor",
|
||||
"motor_propulsion": "Maskinens resa",
|
||||
"sails_picker_show_more": "Visa alla segel",
|
||||
"sails_picker_show_less": "Visa mindre",
|
||||
"motor_hours": "Maskintimmar (totalt)",
|
||||
"fuel_per_motor_hour": "Förbrukning per maskintimme",
|
||||
"event_distance": "Avstånd (sm)",
|
||||
"export_csv": "Hämta CSV.",
|
||||
"share_csv": "Aktie",
|
||||
"export_pdf": "Hämta PDF.",
|
||||
"exporting_pdf": "PDF genereras...",
|
||||
"photos_title": "Fotobilagor (E2E-krypterade)",
|
||||
"photo_caption_label": "Fotobeskrivning/etikett (valfritt)",
|
||||
"photo_caption_placeholder": "t.ex. sätta segel nära hamninloppet",
|
||||
"photo_btn": "Ta foto / ladda upp",
|
||||
"photo_processing": "Håller på att bearbetas...",
|
||||
"no_photos": "Inga foton kopplade till denna resdag ännu.",
|
||||
"photo_delete_confirm": "Är du säker på att du vill radera det här fotot permanent?",
|
||||
"confirm_yes": "Ja",
|
||||
"confirm_no": "Nej",
|
||||
"track_upload_title": "GPS-spårning (fil)",
|
||||
"track_upload_points": "Poäng",
|
||||
"gps_tracking_btn_gpx": "Ladda ner spårfil",
|
||||
"gps_track_upload_help": "Dra en GPX-, KML- eller GeoJSON-fil hit eller klicka för att välja",
|
||||
"gps_track_upload_btn": "Ladda upp GPS-spår",
|
||||
"gps_track_delete": "Ta bort spårfil",
|
||||
"gps_track_delete_confirm": "Är du säker på att du vill radera den här spårfilen permanent?",
|
||||
"track_distance": "GPS-rutt (sm)",
|
||||
"track_speed_max": "Max. hastighet Hastighet (kn)",
|
||||
"track_speed_avg": "Ø Hastighet (kn)",
|
||||
"track_map_title": "GPS-spår på OpenSeaMap",
|
||||
"track_map_start": "Start",
|
||||
"track_map_end": "Mål",
|
||||
"track_map_speed_slow": "långsamt",
|
||||
"track_map_speed_fast": "snabb",
|
||||
"track_map_error": "Kartan kunde inte läsas in.",
|
||||
"exporting": "Export...",
|
||||
"share_unsupported": "Delning stöds inte på den här enheten. Filen har laddats ner istället.",
|
||||
"invite_crew": "Bjud in besättningen",
|
||||
"invite_link_copied": "Länk till inbjudan kopierad till urklipp!",
|
||||
"invite_link_desc": "Dela den här länken med besättningsmedlemmar för att ge dem skrivrättigheter till loggboken.",
|
||||
"collaborators_list": "Medlemmar / Besättning",
|
||||
"revoke": "Ta bort",
|
||||
"revoke_confirm": "Är du säker på att du vill återkalla den här besättningsmedlemmens åtkomst?",
|
||||
"invite_role": "Roll",
|
||||
"invite_expires": "Länken är giltig i 48 timmar",
|
||||
"nmea_import_title": "Import NMEA log",
|
||||
"nmea_import_intro": "Upload a .nmea file from your onboard logger. The app suggests journal entries — you choose what to import.",
|
||||
"nmea_import_btn": "Import NMEA",
|
||||
"nmea_file_label": "NMEA file",
|
||||
"nmea_stats": "{{lines}} sentences parsed · types: {{types}}",
|
||||
"nmea_warn_no_position": "No position sentences found — track and GPS fields may stay empty.",
|
||||
"nmea_mode_label": "Generate journal entries",
|
||||
"nmea_mode_interval": "By time interval",
|
||||
"nmea_mode_change": "On significant change",
|
||||
"nmea_mode_both": "Both (merge)",
|
||||
"nmea_interval_label": "Interval (minutes)",
|
||||
"nmea_import_track": "Import GPS track from NMEA",
|
||||
"nmea_preview": "Preview",
|
||||
"nmea_preview_hint": "{{count}} suggested journal entries",
|
||||
"nmea_select_all": "Select all",
|
||||
"nmea_select_none": "Select none",
|
||||
"nmea_source_interval": "Interval",
|
||||
"nmea_source_change": "Event",
|
||||
"nmea_apply": "Apply to journal",
|
||||
"nmea_back": "Back",
|
||||
"nmea_cancel": "Cancel",
|
||||
"nmea_archive_question": "Archive raw log locally? (This device only, not synced.)",
|
||||
"nmea_archive_keep": "Archive",
|
||||
"nmea_archive_discard": "Discard",
|
||||
"nmea_archive_stored": "NMEA archived: {{name}}",
|
||||
"nmea_archive_delete_confirm": "Delete archived NMEA log from this device?",
|
||||
"nmea_error_no_samples": "No usable NMEA sentences in the file.",
|
||||
"nmea_error_parse": "Could not read NMEA file.",
|
||||
"nmea_error_read": "Could not read file.",
|
||||
"nmea_error_no_file": "Please choose an NMEA file first.",
|
||||
"nmea_error_no_selection": "Please select at least one journal entry.",
|
||||
"nmea_remark_interval": "NMEA interval",
|
||||
"nmea_remark_uncertain": "uncertain",
|
||||
"nmea_remark_depth": "Depth {{depth}} m",
|
||||
"nmea_change_course": "Course change {{from}}° → {{to}}°",
|
||||
"nmea_change_wind": "Wind {{from}}° → {{to}}°",
|
||||
"nmea_change_wind_speed": "Wind {{from}} → {{to}} kn",
|
||||
"nmea_change_pressure": "Pressure {{from}} → {{to}} hPa",
|
||||
"nmea_change_depth": "Depth {{from}} → {{to}} m",
|
||||
"nmea_change_engine_start": "Engine on ({{rpm}} rpm)",
|
||||
"nmea_change_engine_stop": "Engine off",
|
||||
"nmea_change_autopilot_on": "Autopilot on",
|
||||
"nmea_change_autopilot_off": "Autopilot off",
|
||||
"nmea_change_gps_lost": "GPS fix lost",
|
||||
"nmea_change_gps_regained": "GPS fix restored",
|
||||
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
|
||||
"nmea_change_departure": "Departure / underway",
|
||||
"nmea_change_anchor": "Anchored / stop",
|
||||
"nmea_change_speed": "Speed {{from}} → {{to}} kn",
|
||||
"nmea_warn_duplicate_file": "This NMEA file has already been imported. Importing the same file again will add duplicate journal entries."
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dina loggböcker",
|
||||
"subtitle": "Välj en loggbok eller skapa en ny för att hantera dina resor.",
|
||||
"create_btn": "Skapa loggbok",
|
||||
"new_logbook_placeholder": "Loggbokens eller båtens namn",
|
||||
"logout": "Logga ut",
|
||||
"logged_in_as": "Inloggad som {{name}}",
|
||||
"delete_confirm": "Är du säker på att du vill radera den här loggboken permanent? Alla lokala data och serverkopior kommer att förstöras.\n\nTips: Skapa en säkerhetskopia (.daagbok.json) i förväg under Inställningar → Säkerhetskopiering och återställning om du vill behålla data senare.",
|
||||
"no_logbooks": "Inga loggböcker hittades. Skapa din första loggbok för att komma igång!",
|
||||
"loading": "Loggböckerna är fulla...",
|
||||
"status_synced": "Synkroniserad",
|
||||
"status_local": "Endast lokal cache",
|
||||
"delete_btn": "Radera loggbok",
|
||||
"section_owned": "Mina loggböcker",
|
||||
"section_shared": "Delade loggböcker",
|
||||
"section_shared_hint": "Du har blivit inbjuden som besättningsmedlem. Skepparens profil och inställningar tillhör ägaren.",
|
||||
"role_owner": "Egen loggbok",
|
||||
"role_owner_hint": "Du är ägare och skeppare till denna loggbok",
|
||||
"role_crew": "Tillträde för besättningen",
|
||||
"role_crew_hint": "Inbjuden loggbok - du kan arbeta som besättning och underteckna den",
|
||||
"role_read": "Endast läsning",
|
||||
"role_read_hint": "Delad loggbok - endast visning, ingen redigering",
|
||||
"open_profile": "Öppna profil för {{name}}",
|
||||
"edit_title": "Byt namn på loggbok",
|
||||
"edit_placeholder": "Nytt namn på loggboken",
|
||||
"edit_success": "Loggboken har framgångsrikt bytt namn",
|
||||
"edit_btn": "Byt namn på",
|
||||
"filter_label": "Filtrera loggböcker",
|
||||
"filter_placeholder": "Namn, årtal eller datum ...",
|
||||
"filter_clear": "Återställ filter",
|
||||
"filter_results": "{{count}} Träffar",
|
||||
"filter_no_results": "Inga loggböcker matchar din sökning. Försök med ett annat namn eller ett annat år.",
|
||||
"sort_label": "Sortera",
|
||||
"sort_by_label": "Sortera efter",
|
||||
"sort_by_name": "Namn",
|
||||
"sort_by_date": "datum",
|
||||
"sort_dir_label": "Sekvens",
|
||||
"sort_asc": "Stigande",
|
||||
"sort_desc": "Nedåtgående",
|
||||
"sort_name_asc": "Namn A till Ö",
|
||||
"sort_name_desc": "Namn Z till A",
|
||||
"sort_date_asc": "Äldst först",
|
||||
"sort_date_desc": "Nyast först"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Användarprofil",
|
||||
"subtitle": "Konto, Passkeys och statistik för {{name}}",
|
||||
"back": "Tillbaka till instrumentpanelen",
|
||||
"loading": "Profilen håller på att laddas...",
|
||||
"load_error": "Profilen kunde inte laddas.",
|
||||
"copy_failed": "Kopiering misslyckades.",
|
||||
"processing": "Håller på att bearbetas...",
|
||||
"identity_title": "Kontots identitet",
|
||||
"username": "Användarens namn",
|
||||
"user_id": "Användar-ID",
|
||||
"copy_user_id": "Kopiera användar-ID",
|
||||
"account_since": "Konto sedan",
|
||||
"prf_status": "Passkey härledning av nyckel (PRF)",
|
||||
"prf_active": "Aktiv",
|
||||
"prf_inactive": "Inte konfigurerad",
|
||||
"passkeys_title": "Passkeys",
|
||||
"passkeys_desc": "Registrera en separat Passkey på varje enhet. Detta gör att du kan logga in även efter att du bytt plattform.",
|
||||
"passkeys_empty": "Inga Passkeys hittades.",
|
||||
"add_passkey_btn": "Lägg till ny Passkey",
|
||||
"add_passkey_success": "Passkey har lagts till.",
|
||||
"add_passkey_failed": "Passkey kunde inte läggas till.",
|
||||
"remove_passkey_btn": "Ta bort Passkey.",
|
||||
"remove_passkey_last_title": "Senaste Passkey.",
|
||||
"remove_passkey_last_desc": "Den enda Passkey kan inte tas bort utan att du förlorar åtkomsten till ditt konto. Om du vill radera kontot helt använder du riskzonen längst ner på den här sidan.",
|
||||
"remove_passkey_failed": "Passkey kunde inte tas bort.",
|
||||
"remove_passkey_confirm_title": "Ta bort Passkey?",
|
||||
"remove_passkey_confirm_desc": "Denna enhet kan sedan inte längre logga in med denna Passkey.",
|
||||
"remove_passkey_confirm_yes": "Ta bort",
|
||||
"remove_passkey_confirm_no": "Avbryt",
|
||||
"pin_title": "Lokal PIN-kod",
|
||||
"pin_status": "Status",
|
||||
"pin_active": "Aktiv på den här enheten",
|
||||
"pin_inactive": "Inte konfigurerad",
|
||||
"pin_confirm_label": "Bekräfta PIN-kod",
|
||||
"pin_confirm_placeholder": "Ange PIN-koden igen",
|
||||
"pin_set_btn": "Ange PIN-kod",
|
||||
"pin_change_btn": "Ändra PIN-kod",
|
||||
"pin_remove_btn": "Ta bort PIN-koden",
|
||||
"pin_saved": "PIN-koden sparad.",
|
||||
"pin_save_failed": "PIN-koden kunde inte räddas.",
|
||||
"pin_mismatch": "PIN-koderna stämmer inte överens.",
|
||||
"pin_length_error": "PIN-koden måste innehålla minst 4 tecken.",
|
||||
"pin_no_session": "Sessionen har löpt ut - vänligen registrera dig igen.",
|
||||
"remove_pin_confirm_title": "Ta bort PIN-koden?",
|
||||
"remove_pin_confirm_desc": "Du måste logga in igen på den här enheten med Passkey eller återställningsnyckel.",
|
||||
"remove_pin_confirm_yes": "Ta bort PIN-koden",
|
||||
"remove_pin_confirm_no": "Avbryt",
|
||||
"security_title": "Checklista för säkerhet",
|
||||
"security_desc": "Översikt över de viktigaste skyddsmekanismerna för ditt konto.",
|
||||
"security_passkeys_ok": "Minst en Passkey registrerad",
|
||||
"security_passkeys_missing": "Nej Passkey registrerad",
|
||||
"security_prf_ok": "Avledning av PRF-nyckel aktiv",
|
||||
"security_prf_missing": "PRF inte upprättad",
|
||||
"security_pin_ok": "Lokal PIN-kod på den här enheten",
|
||||
"security_pin_missing": "Ingen lokal PIN-kod",
|
||||
"security_recovery_ok": "Uppsättning av återställningsnyckel",
|
||||
"security_recovery_hint": "De 12 orden visades under registreringen. Håll dem offline och åtskilda från enheten. Du kan skapa en ny nyckel nedan - den gamla kommer då att bli ogiltig.",
|
||||
"recovery_rotate_btn": "Skapa en ny återställningsnyckel",
|
||||
"recovery_rotate_confirm_title": "Skapa en ny återställningsnyckel?",
|
||||
"recovery_rotate_confirm_desc": "Den tidigare nyckeln på 12 ord blir ogiltig omedelbart. Se till att du förvarar den nya nyckeln säkert innan du fortsätter.",
|
||||
"recovery_rotate_confirm_yes": "Skapa ny nyckel",
|
||||
"recovery_rotate_confirm_no": "Avbryt",
|
||||
"recovery_rotate_new_warning": "VIKTIGT: Skriv ner dessa 12 ord och förvara dem offline. Den tidigare återställningsnyckeln är nu ogiltig.",
|
||||
"recovery_rotate_failed": "Återställningsnyckel kunde inte skapas.",
|
||||
"recovery_rotate_no_session": "Krypteringssessionen har löpt ut - logga ut och logga in igen och försök sedan igen.",
|
||||
"device_title": "Denna enhet",
|
||||
"device_desc": "Lokal cache, synkroniseringsstatus och snabb inloggning i den här webbläsaren.",
|
||||
"device_sync_pending": "{{count}} väntande synkroniseringsposter",
|
||||
"device_sync_ok": "Alla lokala ändringar synkroniseras",
|
||||
"device_remembered": "Konto för snabb inloggning sparat på den här enheten",
|
||||
"device_not_remembered": "Kontot finns inte med i listan för snabb inloggning",
|
||||
"device_forget_btn": "Glömt konto på den här enheten",
|
||||
"device_forget_confirm_title": "Ta bort snabb inloggning?",
|
||||
"device_forget_confirm_desc": "Kontot försvinner från snabbinloggningslistan på den här enheten. Din session och dina lokala loggböcker behålls.",
|
||||
"device_forget_confirm_yes": "Ta bort",
|
||||
"device_forget_confirm_no": "Avbryt",
|
||||
"passkey_label": "Namn för ny Passkey (valfritt)",
|
||||
"passkey_label_placeholder": "z. t.ex. MacBook, iPhone",
|
||||
"passkey_rename_btn": "Spara namn",
|
||||
"passkey_rename_success": "Passkey namn sparat.",
|
||||
"passkey_rename_failed": "Passkey-Namnet kunde inte sparas.",
|
||||
"passkey_unnamed": "Utan titel Passkey",
|
||||
"stats_title": "Statistik",
|
||||
"stats_subtitle": "Om alla dina loggböcker på den här enheten",
|
||||
"stats_logbooks": "Loggböcker",
|
||||
"stats_account_since": "Konto sedan",
|
||||
"stats_shared_logbooks": "Delade loggböcker",
|
||||
"appearance_title": "App & visualisering",
|
||||
"appearance_desc": "Designen och färgschemat gäller för hela appen på den här enheten.",
|
||||
"theme_label": "Appens designstil",
|
||||
"theme_auto": "Automatisk (OS-detektering)",
|
||||
"theme_ocean": "Ocean (glasmorfism)",
|
||||
"theme_material": "Material (Android)",
|
||||
"theme_cupertino": "Cupertino (iOS)",
|
||||
"color_scheme_label": "Ljust eller mörkt läge",
|
||||
"color_scheme_auto": "Automatisk (system)",
|
||||
"color_scheme_light": "Ljus",
|
||||
"color_scheme_dark": "Mörk",
|
||||
"integrations_title": "Integrationer",
|
||||
"owm_key": "OpenWeatherMap API-nyckel",
|
||||
"owm_help": "Valfritt: egen OpenWeatherMap API-nyckel. Om inget anges används nyckeln på serversidan från operatörskonfigurationen.",
|
||||
"prefs_save": "Spara",
|
||||
"prefs_saving": "Kommer att sparas...",
|
||||
"prefs_saved": "Sparade",
|
||||
"tour_title": "App-turné",
|
||||
"tour_desc": "Låt dig vägledas genom de viktigaste områdena i appen igen.",
|
||||
"tour_restart": "Starta resan igen",
|
||||
"push_title": "Push-meddelanden",
|
||||
"push_desc": "Som loggboksägare får du ett meddelande när inbjudna besättningsmedlemmar synkroniserar ändringar. Inget innehåll överförs i klartext.",
|
||||
"push_enable": "Meddela oss om förändringar i besättningen",
|
||||
"push_active": "Push-meddelanden är aktiva på den här enheten.",
|
||||
"push_unsupported": "Push-meddelanden stöds inte i den här webbläsaren.",
|
||||
"push_denied_hint": "Meddelanden är blockerade. Tillåt dem i webbläsarens eller enhetens inställningar.",
|
||||
"push_ios_install_hint": "På iPhone/iPad: Lägg till app på startskärmen (iOS 16.4+) för att använda push.",
|
||||
"push_error": "Push-meddelanden kunde inte aktiveras."
|
||||
},
|
||||
"crew": {
|
||||
"title": "Profiler för skeppare och besättning",
|
||||
"skipper_section": "Skepparens profil",
|
||||
"skipper_read_only_hint": "Skepparens profil kan endast redigeras av loggbokens ägare.",
|
||||
"crew_section": "Besättningslista",
|
||||
"add_crew": "Lägg till besättningsmedlem",
|
||||
"edit_crew": "Redigera besättningsmedlem",
|
||||
"no_crew": "Inga besättningsmedlemmar har lagts till ännu.",
|
||||
"max_crew": "Maximalt antal på 5 besättningsmedlemmar uppnås.",
|
||||
"name": "Namn",
|
||||
"address": "adress",
|
||||
"birthdate": "Födelsedag",
|
||||
"phone": "Telefonnummer",
|
||||
"nationality": "Nationalitet",
|
||||
"passport": "Pass/ID-nummer",
|
||||
"bloodtype": "Blodgrupp",
|
||||
"allergies": "Allergier",
|
||||
"diseases": "Redan existerande tillstånd/sjukdomar",
|
||||
"save": "Spara skeppardata",
|
||||
"save_member": "Spara medlem",
|
||||
"saved": "Skepparens profil har sparats!",
|
||||
"loading": "Besättningsfilerna är laddade...",
|
||||
"delete_confirm": "Är du säker på att du vill ta bort den här besättningsmedlemmen?"
|
||||
},
|
||||
"deviation": {
|
||||
"title": "Tabell för kompassavvikelse",
|
||||
"subtitle": "Ange den magnetiska kompassdeflektionen (deflektion) för kurser (MgK) från 000° till 360° i steg om 10°.",
|
||||
"heading": "MgK",
|
||||
"deviation": "Distraktion",
|
||||
"save": "Spara kalibreringsrutan",
|
||||
"saving": "Kommer att sparas...",
|
||||
"saved": "Kalibreringsnätet har sparats framgångsrikt!",
|
||||
"loading": "Kalibreringsbordet är laddat..."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Inställningar för loggbok",
|
||||
"subtitle": "Dela, säkerhetskopiera och samarbeta för den här loggboken.",
|
||||
"select_logbook_hint": "Välj en loggbok för att redigera dess inställningar.",
|
||||
"no_key": "Ingen OpenWeatherMap API-nyckel tillgänglig. Spara din egen nyckel i användarprofilen eller kontakta operatören.",
|
||||
"weather_success": "Väderdata har hämtats framgångsrikt!",
|
||||
"weather_error": "Hämtning av väderdata misslyckades. Kontrollera API-nyckeln och anslutningen.",
|
||||
"weather_date_mismatch": "Väderdata kan endast hämtas för idag ({{today}}). Denna loggbokspost är daterad {{date}}.",
|
||||
"gps_error": "Ange en plats eller bestäm GPS-koordinaterna.",
|
||||
"share_title": "Aktieloggbok (skrivskyddad)",
|
||||
"share_desc": "Aktivera det här alternativet för att skapa en publik, skrivskyddad länk. Alla som har länken kan se dina resor, båtprofiler och besättning. Krypteringsnycklarna överförs aldrig till servern (de finns kvar i hashdelen av URL:en).",
|
||||
"share_privacy_warning": "Rekommendation: Dela endast den här länken privat (t.ex. via e-post eller messenger), inte på sociala medier.",
|
||||
"share_enable": "Aktivera offentlig länk",
|
||||
"share_copied": "Länk kopierad!",
|
||||
"share_copy_btn": "Kopiera länk",
|
||||
"link_qr_hint": "Skanna QR-koden med mobilen",
|
||||
"link_qr_alt": "QR-kod för länken",
|
||||
"danger_zone_title": "Farlig zon",
|
||||
"danger_zone_desc": "Om du raderar ditt konto raderas oåterkalleligen alla dina Passkey, loggböcker, fartygsdata, besättningsprofiler, reseanteckningar och E2E-nycklar. Denna åtgärd kan inte ångras.",
|
||||
"delete_account_btn": "Ta bort konto oåterkalleligt",
|
||||
"delete_account_confirm_title": "Radera konto?",
|
||||
"delete_account_confirm_desc": "Är du helt säker på att du oåterkalleligen vill radera ditt konto och alla tillhörande loggböcker och E2E-krypterade data?",
|
||||
"delete_account_confirm_yes": "Ja, radera konto och all data",
|
||||
"delete_account_confirm_no": "Avbryt",
|
||||
"delete_account_failed": "Kontot kunde inte raderas. Vänligen försök igen.",
|
||||
"delete_backup_hint": "Tips: Skapa säkerhetskopior av dina loggböcker (.daagbok.json) i inställningarna för varje loggbok innan du raderar dem.",
|
||||
"deleting_account": "Kontot kommer att raderas...",
|
||||
"invite_push_prompt_title": "Aktivera push-meddelanden?",
|
||||
"invite_push_prompt_message": "Så snart inbjudna besättningsmedlemmar synkroniserar ändringar kan du bli informerad via push. Inget loggboksinnehåll skickas i klartext.",
|
||||
"invite_push_prompt_ios_message": "Så snart besättningsmedlemmar synkroniserar ändringar kan du bli informerad via push. På iPhone/iPad: Lägg till appen på startskärmen (iOS 16.4+) och aktivera sedan push i användarprofilen.",
|
||||
"invite_push_prompt_enable": "Aktivera nu",
|
||||
"invite_push_prompt_later": "Senare",
|
||||
"invite_push_prompt_success": "Push-meddelanden är aktiva på den här enheten.",
|
||||
"backup_title": "Säkerhetskopiering och återställning",
|
||||
"backup_desc": "Komplett krypterad säkerhetskopia av denna loggbok (poster, foton, GPS-spår, besättning, fartyg). Skyddad med lösenfras för säkerhetskopian - för återställning till detta eller ett nytt konto.",
|
||||
"backup_export_title": "Skapa säkerhetskopia",
|
||||
"backup_export_desc": "Laddar ner alla lokala data som .daagbok.json. Förvara filen och lösenfrasen separat och säkert.",
|
||||
"backup_restore_title": "Återställ säkerhetskopian",
|
||||
"backup_restore_desc": "Återställer en säkerhetskopia till ditt nuvarande konto - även efter att du har registrerat ett nytt konto.",
|
||||
"backup_passphrase": "Lösenord för säkerhetskopiering",
|
||||
"backup_passphrase_placeholder": "Minst 8 tecken",
|
||||
"backup_passphrase_confirm": "Bekräfta lösenfras",
|
||||
"backup_passphrase_short": "Säkerhetskopians lösenfras måste vara minst 8 tecken lång.",
|
||||
"backup_passphrase_mismatch": "Lösenfraserna stämmer inte överens.",
|
||||
"backup_wrong_passphrase": "Lösenordet är felaktigt eller säkerhetskopian är skadad.",
|
||||
"backup_export_btn": "Ladda ner backup",
|
||||
"backup_exporting": "Säkerhetskopian skapas...",
|
||||
"backup_export_success": "Säkerhetskopia skapad ({{count}} resdagar).",
|
||||
"backup_file_label": "Säkerhetskopieringsfil (.daagbok.json)",
|
||||
"backup_preview_btn": "Kontrollera innehåll",
|
||||
"backup_previewing": "Check...",
|
||||
"backup_restore_btn": "Återställ",
|
||||
"backup_restoring": "Kommer att återställas...",
|
||||
"backup_restore_success": "Loggbok \"{{title}}\" har återställts.",
|
||||
"backup_restore_cancelled": "Återhämtning avbruten.",
|
||||
"backup_invalid_json": "Filen är inte en giltig JSON-fil.",
|
||||
"backup_invalid_format": "Okänt eller föråldrat backupformat.",
|
||||
"backup_not_owner": "Endast loggbokens ägare kan skapa säkerhetskopior.",
|
||||
"backup_not_authenticated": "Logga in för att återställa en säkerhetskopia.",
|
||||
"backup_id_conflict": "En loggbok med detta ID finns redan.",
|
||||
"backup_overwrite_confirm": "Den befintliga loggboken med samma ID ersätts. Fortsätter du?",
|
||||
"backup_new_id_confirm": "Importera säkerhetskopian som en ny loggbok med ett nytt ID?",
|
||||
"backup_stat_entries": "{{count}} Resdagar",
|
||||
"backup_stat_photos": "{{count}} Foton",
|
||||
"backup_stat_crew": "{{count}} Besättningens uppgifter",
|
||||
"backup_stat_tracks": "{{count}} GPS-spår",
|
||||
"backup_exported_at": "Exporterad: {{date}}"
|
||||
},
|
||||
"disclaimer": {
|
||||
"title": "Viktiga anmärkningar",
|
||||
"intro": "Läs följande anvisningar innan du använder Kapteins Daagbok.",
|
||||
"e2e_title": "End-to-end-kryptering",
|
||||
"e2e_body": "Dina loggboksdata är krypterade från början till slut. Endast du - eller personer med din nyckel - kan läsa innehållet. Endast krypterade data lagras på servern.",
|
||||
"pwa_title": "Progressiv webbapplikation (PWA)",
|
||||
"pwa_body": "Kapteins Daagbok körs som en progressiv webbapp i din webbläsare och kan installeras på din enhet - på samma sätt som en native-app, utan en appbutik.",
|
||||
"storage_title": "Lokal lagring och synkronisering",
|
||||
"storage_body": "Dina data lagras lokalt på din enhet (IndexedDB). Ändringar synkroniseras med servern när en internetanslutning är aktiv. Du kan fortsätta att arbeta utan anslutning, synkroniseringen sker senare.",
|
||||
"free_title": "Kostnadsfritt och reklamfritt",
|
||||
"free_body": "Kapteins Daagbok är kostnadsfritt och innehåller ingen reklam.",
|
||||
"liability_title": "Ansvarsfriskrivning",
|
||||
"liability_body": "Användningen av appen sker på egen risk. Inget ansvar accepteras för skador som uppstår till följd av användningen av appen - inklusive felaktiga eller ofullständiga loggboksanteckningar, förlust av data eller tekniska fel.",
|
||||
"warranty_title": "Ingen garanti",
|
||||
"warranty_body": "Ingen garanti ges för tjänstens funktion, korrekthet eller tillgänglighet. Driften kan när som helst avbrytas, begränsas eller ställas in.",
|
||||
"copyright": "© 2026 KnorrLabs, Markus F.J. Busche",
|
||||
"accept": "Acceptera och fortsätt",
|
||||
"close": "Nära",
|
||||
"button_title": "Anmärkningar och ansvarsfriskrivning"
|
||||
},
|
||||
"feedback": {
|
||||
"button_title": "Skicka feedback",
|
||||
"title": "Återkoppling",
|
||||
"intro": "Dela med dig av buggar, idéer eller allmän feedback. Ditt meddelande kommer att skickas till projektgruppen via en säker meddelandekanal.",
|
||||
"category_label": "Kategori",
|
||||
"category_general": "Allmänt",
|
||||
"category_bug": "Rapportera fel",
|
||||
"category_feature": "Begäran om funktion",
|
||||
"category_translation": "Översättningsfel",
|
||||
"contact_label": "E-post (valfritt)",
|
||||
"contact_placeholder": "deine@email.beispiel",
|
||||
"message_label": "Meddelande",
|
||||
"message_placeholder": "Beskriv din feedback...",
|
||||
"send": "Skicka",
|
||||
"sending": "Kommer att skickas...",
|
||||
"cancel": "Avbryt",
|
||||
"success": "Tack så mycket! Din feedback har skickats.",
|
||||
"error_send": "Feedback kunde inte skickas. Vänligen försök igen senare.",
|
||||
"error_invalid_email": "Vänligen ange en giltig e-postadress.",
|
||||
"error_not_configured": "Feedback är inte tillgängligt på den här servern.",
|
||||
"error_rate_limited": "För många feedbackmeddelanden på kort tid. Vänligen vänta några minuter.",
|
||||
"error_spam": "Det här meddelandet kunde inte skickas. Vänligen omformulera det."
|
||||
},
|
||||
"demo": {
|
||||
"logbook_title": "Demo loggbok Östersjön",
|
||||
"badge": "Demo",
|
||||
"public_banner": "Skrivskyddad demovy",
|
||||
"cta_register": "Skapa konto",
|
||||
"back_to_login": "Till registreringen"
|
||||
},
|
||||
"invitation": {
|
||||
"error_invalid_key": "Länken till inbjudan är kryptografiskt ogiltig (nyckeln är felaktig).",
|
||||
"error_missing_key": "Länken till inbjudan innehåller ingen dekrypteringsnyckel (#key=...). Vänligen använd den fullständiga länken från ägaren.",
|
||||
"error_expired": "Denna inbjudan har löpt ut (giltig i 48 timmar).",
|
||||
"error_invalid_token": "Inbjudan ogiltig.",
|
||||
"error_load_failed": "Inbjudan kunde inte läsas in.",
|
||||
"error_incomplete_session": "Sessionen är ofullständig - logga in igen (användar-ID saknas).",
|
||||
"error_accept_failed": "Anslutningen misslyckades.",
|
||||
"error_login_failed": "Passkey Inloggningen misslyckades.",
|
||||
"error_username_missing": "Användarnamnet kunde inte fastställas - vänligen logga in igen.",
|
||||
"error_register_failed": "Registreringen misslyckades.",
|
||||
"loading_joining": "Ansluter sig...",
|
||||
"loading_checking": "Inbjudan kommer att kontrolleras...",
|
||||
"loading_unlocking": "Loggboken är upplåst och synkroniserad...",
|
||||
"loading_retrieving_key": "Ladda ner krypteringsnyckel...",
|
||||
"error_title": "Fel i inbjudan",
|
||||
"back_to_start": "Tillbaka till början",
|
||||
"title": "Inbjudan till loggbok",
|
||||
"invited_by": "Inbjudan från",
|
||||
"vessel_logbook": "Fartyg / Loggbok",
|
||||
"signed_in_preparing": "Registrerad som {{username}}. Anslutning förbereds...",
|
||||
"join_again": "Gå med igen",
|
||||
"login_or_register_hint": "Logga in eller registrera ett konto för att gå med i loggboken.",
|
||||
"or_sign_up": "ELLER REGISTRERA DIG IGEN",
|
||||
"register_crew_account": "Skapa ett nytt konto för besättningen",
|
||||
"username_label": "Användarens namn",
|
||||
"create_passkey": "Skapa Passkey.",
|
||||
"switch_language_en": "Engelska",
|
||||
"switch_language_de": "Tysk"
|
||||
},
|
||||
"stats": {
|
||||
"title": "Statistik",
|
||||
"subtitle": "Översikt över rutter, förbrukning och typ av körning",
|
||||
"scope_label": "Utvärderingsområde",
|
||||
"scope_logbook": "Denna loggbok",
|
||||
"scope_account": "Alla loggböcker",
|
||||
"loading": "Statistiken är beräknad...",
|
||||
"no_data": "Inga resdagar tillgängliga ännu.",
|
||||
"total_distance": "Totalt avstånd",
|
||||
"travel_days": "Resdagar",
|
||||
"sail_distance": "Under segel",
|
||||
"motor_distance": "Maskinens resa",
|
||||
"motor_hours_total": "Totalt antal maskintimmar",
|
||||
"daily_motor_hours": "Maskintimmar per resdag",
|
||||
"avg_motor_hours": "Ø maskintimmar per resdag",
|
||||
"unknown_propulsion": "Okänd",
|
||||
"fuel_total": "Totalt bränsle",
|
||||
"water_total": "Totalt vatten",
|
||||
"daily_etmal": "Dagliga tider",
|
||||
"daily_consumption": "Daglig konsumtion",
|
||||
"route_overview": "Vägbeskrivning",
|
||||
"route_map_title": "Översikt över rutten",
|
||||
"propulsion_title": "Segel vs. maskin",
|
||||
"propulsion_hint": "Fördelningen baseras på loggbokshändelser per resdag, inte på GPS-segment.",
|
||||
"avg_distance": "Ø per resdag",
|
||||
"avg_fuel": "Ø Bränsle",
|
||||
"avg_water": "Ø Vatten",
|
||||
"fuel_per_nm": "Bränsle per sm",
|
||||
"fuel_per_motor_hour": "Bränsle per maskintimme",
|
||||
"daily_fuel_per_motor_hour": "Bränsleförbrukning per maskintimme och resdag",
|
||||
"fuel_legend": "Bränsle",
|
||||
"water_legend": "Vatten",
|
||||
"unit_nm": "sm",
|
||||
"unit_h": "h",
|
||||
"unit_l": "L",
|
||||
"day_label": "Dag {{day}}__.",
|
||||
"account_logbooks": "Loggböcker i en överblick",
|
||||
"col_logbook": "Loggbok",
|
||||
"event_series_title": "Händelseförlopp",
|
||||
"event_series_hint": "Kronologiska värden från händelseloggen.",
|
||||
"event_series_pressure": "Lufttryck",
|
||||
"event_series_wind": "Vind",
|
||||
"event_series_motor": "Motor",
|
||||
"event_series_empty": "Inga poster ännu."
|
||||
},
|
||||
"tour": {
|
||||
"skip": "Hoppa över turen",
|
||||
"back": "Tillbaka",
|
||||
"next": "Ytterligare",
|
||||
"finish": "Färdig",
|
||||
"progress": "Steg {{current}} från {{total}}.",
|
||||
"steps": {
|
||||
"welcome": {
|
||||
"title": "Välkommen ombord!",
|
||||
"body": "Vi har skapat en demo-loggbok med tre dagars resa i Kielfjorden åt dig. Du kan när som helst radera exempelposterna om du vill starta din egen loggbok. Den här korta rundturen visar dig de viktigaste funktionerna."
|
||||
},
|
||||
"welcome_public": {
|
||||
"title": "Välkommen ombord!",
|
||||
"body": "Utforska vår demologgbok med tre dagars resor i Kielfjorden - utan konto. Den här korta rundturen visar dig fartygsdata, besättning och loggboksanteckningar."
|
||||
},
|
||||
"nav_logs": {
|
||||
"title": "Loggboksanteckningar",
|
||||
"body": "Det är här du hanterar dina resdagar - avresa, destination, väder, bränslenivåer och GPS-spår."
|
||||
},
|
||||
"entry_list": {
|
||||
"title": "Dina resdagar",
|
||||
"body": "Varje kort representerar en resdag. Tryck på en post för att visa eller redigera detaljer."
|
||||
},
|
||||
"entry_open": {
|
||||
"title": "Öppen resdag",
|
||||
"body": "Så här ser en komplett loggboksanteckning ut - med händelser, tanknivåer och mycket mer."
|
||||
},
|
||||
"entry_track": {
|
||||
"title": "GPS-spårning",
|
||||
"body": "Ladda upp GPX-filer eller visa redan sparade rutter på kartan - inklusive avstånd och hastighet."
|
||||
},
|
||||
"nav_vessel": {
|
||||
"title": "Fartygsdata",
|
||||
"body": "Ange namn, dimensioner och tekniska data för din yacht - fyll i en gång, tillgänglig för alla resdagar."
|
||||
},
|
||||
"nav_crew": {
|
||||
"title": "Besättningslista",
|
||||
"body": "Hantera besättningsmedlemmar och tilldela dem resdagar senare."
|
||||
},
|
||||
"nav_stats": {
|
||||
"title": "Kontrollpanel för statistik",
|
||||
"body": "Här kan du se körsträckor, bränsleförbrukning, ruttkartor och körandelar - automatiskt beräknade från dina loggboksanteckningar."
|
||||
},
|
||||
"nav_feedback": {
|
||||
"title": "Skicka feedback",
|
||||
"body": "Du kan använda det här formuläret för att skicka fel, idéer eller allmän feedback direkt till projektgruppen - även efter rundturen när som helst med hjälp av ikonen längst upp till höger."
|
||||
},
|
||||
"nav_profile": {
|
||||
"title": "Din användarprofil",
|
||||
"body": "Du kommer åt din personliga profil via skipperknappen högst upp - oavsett vilken loggbok som är aktuell."
|
||||
},
|
||||
"profile_preferences": {
|
||||
"title": "Redovisning & presentation",
|
||||
"body": "Här kan du hantera din konto-identitet, ditt tema och ljus/mörker-läge. Du kan när som helst starta om appturen. Passkeys och säkerhetsinställningar hittar du längre ner i profilen."
|
||||
},
|
||||
"finish": {
|
||||
"title": "Okej!",
|
||||
"body": "Du kommer direkt till instrumentpanelen för statistik. Du kan när som helst starta om turen i din användarprofil. Ha en trevlig resa!"
|
||||
}
|
||||
}
|
||||
},
|
||||
"seo": {
|
||||
"title": "Kapteins Daagbok - Gratis digital loggbok för båtar (reklamfri)",
|
||||
"description": "Gratis, annonsfri digital loggbok för båtar med kryptering från början till slut och Passkey-inloggning. Dokumentera resdagar, GPS-spår, besättnings- och fartygsdata på ett säkert sätt - även offline som PWA.",
|
||||
"keywords": "Yachtloggbok, skeppsdagbok, ombordloggbok, segling, Passkey, E2E kryptering, GPS-spår, sjöfartsloggbok, gratis, reklamfri, gratis, utan reklam",
|
||||
"ogImageAlt": "Kapteins Daagbok Logotyp"
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-102
@@ -1,64 +1,8 @@
|
||||
:root {
|
||||
--text: #6b6375;
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #aa3bff;
|
||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||
--accent-border: rgba(170, 59, 255, 0.5);
|
||||
--social-bg: rgba(244, 243, 236, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||
/* Minimal app shell — component styles live in App.css / themes.css */
|
||||
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, monospace;
|
||||
|
||||
font: 18px/145% var(--sans);
|
||||
letter-spacing: 0.18px;
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--text: #9ca3af;
|
||||
--text-h: #f3f4f6;
|
||||
--bg: #16171d;
|
||||
--border: #2e303a;
|
||||
--code-bg: #1f2028;
|
||||
--accent: #c084fc;
|
||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||
--accent-border: rgba(192, 132, 252, 0.5);
|
||||
--social-bg: rgba(47, 48, 58, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||
}
|
||||
|
||||
#social .button-icon {
|
||||
filter: invert(1) brightness(2);
|
||||
}
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 1126px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border-inline: 1px solid var(--border);
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -66,46 +10,11 @@ body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: var(--heading);
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 56px;
|
||||
letter-spacing: -1.68px;
|
||||
margin: 32px 0;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 36px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
line-height: 118%;
|
||||
letter-spacing: -0.24px;
|
||||
margin: 0 0 8px;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code,
|
||||
.counter {
|
||||
font-family: var(--mono);
|
||||
display: inline-flex;
|
||||
border-radius: 4px;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 15px;
|
||||
line-height: 135%;
|
||||
padding: 4px 8px;
|
||||
background: var(--code-bg);
|
||||
#root {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
+70
-7
@@ -3,14 +3,77 @@ import { createRoot } from 'react-dom/client'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import './themes.css'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import './App.css'
|
||||
import './i18n'
|
||||
import App from './App.tsx'
|
||||
import { applyAppearanceToDocument } from './services/appearance.ts'
|
||||
import {
|
||||
installStaleAssetRecovery,
|
||||
markReloadAttempt,
|
||||
reconcileVersionOnStartup
|
||||
} from './services/pwaStartup.ts'
|
||||
import { redirectToPasskeyCompatibleHostIfNeeded } from './utils/passkeyHost.ts'
|
||||
|
||||
applyAppearanceToDocument()
|
||||
/** Stale PWA precache on localhost can shadow Vite dev modules. */
|
||||
async function clearDevServiceWorkerCaches(): Promise<void> {
|
||||
if (!import.meta.env.DEV || !('serviceWorker' in navigator)) return
|
||||
const regs = await navigator.serviceWorker.getRegistrations()
|
||||
await Promise.all(regs.map((r) => r.unregister()))
|
||||
if ('caches' in window) {
|
||||
const keys = await caches.keys()
|
||||
await Promise.all(keys.map((k) => caches.delete(k)))
|
||||
}
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
function renderBootstrapError(message: string): void {
|
||||
const root = document.getElementById('root')
|
||||
if (!root) return
|
||||
root.innerHTML = `
|
||||
<div class="auth-screen">
|
||||
<div class="auth-card glass" role="alert" style="max-width:420px">
|
||||
<h2 style="margin-top:0">Kapteins Daagbok</h2>
|
||||
<p style="color:var(--app-text-muted);line-height:1.5">${message}</p>
|
||||
<button type="button" class="btn primary" style="width:100%;margin-top:16px" onclick="location.reload()">
|
||||
Neu laden
|
||||
</button>
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
if (redirectToPasskeyCompatibleHostIfNeeded()) {
|
||||
return
|
||||
}
|
||||
|
||||
applyAppearanceToDocument()
|
||||
installStaleAssetRecovery()
|
||||
await clearDevServiceWorkerCaches()
|
||||
|
||||
const startupResult = await reconcileVersionOnStartup()
|
||||
if (startupResult === 'reload') {
|
||||
markReloadAttempt()
|
||||
window.location.reload()
|
||||
return
|
||||
}
|
||||
if (startupResult === 'recovered') {
|
||||
return
|
||||
}
|
||||
|
||||
const rootEl = document.getElementById('root')
|
||||
if (!rootEl) {
|
||||
throw new Error('Missing #root element')
|
||||
}
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
}
|
||||
|
||||
void bootstrap().catch((err) => {
|
||||
console.error('App bootstrap failed:', err)
|
||||
renderBootstrapError(
|
||||
'Die App konnte nicht gestartet werden. Bitte neu laden oder die App vollständig beenden und erneut öffnen.',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -34,9 +34,19 @@ export const PlausibleEvents = {
|
||||
LOCAL_PIN_SET: 'Local PIN Set',
|
||||
LOCAL_PIN_REMOVED: 'Local PIN Removed',
|
||||
DEVICE_FORGOTTEN: 'Device Forgotten',
|
||||
RECOVERY_ROTATED: 'Recovery Rotated'
|
||||
RECOVERY_ROTATED: 'Recovery Rotated',
|
||||
LANGUAGE_CHANGED: 'Language Changed',
|
||||
NMEA_IMPORTED: 'NMEA Imported',
|
||||
NMEA_UPLOADED: 'NMEA Uploaded',
|
||||
LIVE_LOG_OPENED: 'Live Log Opened',
|
||||
LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged',
|
||||
LIVE_LOG_PHOTO_UPLOADED: 'Live Log Photo Uploaded',
|
||||
OWM_WEATHER_FETCHED: 'OWM Weather Fetched'
|
||||
} as const
|
||||
|
||||
/** Where a successful OpenWeatherMap API call originated (no coordinates or place names). */
|
||||
export type OwmAnalyticsSource = 'live_log' | 'entry_editor' | 'entry_editor_gps_lookup'
|
||||
|
||||
export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents]
|
||||
|
||||
export type PlausibleEventProps = Record<string, string | number | boolean>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
applyAppearanceToDocument,
|
||||
resolveAppTheme,
|
||||
resolveColorScheme,
|
||||
type AppTheme,
|
||||
type ResolvedColorScheme
|
||||
} from './appearance.js'
|
||||
import { setColorSchemePreference } from './userPreferences.js'
|
||||
|
||||
const USER_ID = 'appearance-test-user'
|
||||
|
||||
const COMBOS: Array<{ theme: AppTheme; scheme: ResolvedColorScheme }> = [
|
||||
{ theme: 'ocean', scheme: 'dark' },
|
||||
{ theme: 'ocean', scheme: 'light' },
|
||||
{ theme: 'material', scheme: 'dark' },
|
||||
{ theme: 'material', scheme: 'light' },
|
||||
{ theme: 'cupertino', scheme: 'dark' },
|
||||
{ theme: 'cupertino', scheme: 'light' }
|
||||
]
|
||||
|
||||
describe('appearance', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
document.documentElement.className = ''
|
||||
document.documentElement.style.colorScheme = ''
|
||||
document.head.querySelector('meta[name="theme-color"]')?.remove()
|
||||
})
|
||||
|
||||
it.each(COMBOS)('applies $theme · $scheme classes to document', ({ theme, scheme }) => {
|
||||
applyAppearanceToDocument(theme, scheme)
|
||||
|
||||
const root = document.documentElement
|
||||
expect(root.classList.contains(`theme-${theme}`)).toBe(true)
|
||||
expect(root.classList.contains(`scheme-${scheme}`)).toBe(true)
|
||||
expect(root.style.colorScheme).toBe(scheme)
|
||||
})
|
||||
|
||||
it('replaces previous theme classes when switching appearance', () => {
|
||||
applyAppearanceToDocument('ocean', 'dark')
|
||||
applyAppearanceToDocument('material', 'light')
|
||||
|
||||
const root = document.documentElement
|
||||
expect(root.classList.contains('theme-material')).toBe(true)
|
||||
expect(root.classList.contains('theme-ocean')).toBe(false)
|
||||
expect(root.classList.contains('scheme-light')).toBe(true)
|
||||
expect(root.classList.contains('scheme-dark')).toBe(false)
|
||||
})
|
||||
|
||||
it('resolves stored light scheme even when system prefers dark', () => {
|
||||
vi.stubGlobal(
|
||||
'matchMedia',
|
||||
vi.fn().mockReturnValue({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() })
|
||||
)
|
||||
localStorage.setItem('active_userid', USER_ID)
|
||||
setColorSchemePreference(USER_ID, 'light')
|
||||
|
||||
expect(resolveColorScheme()).toBe('light')
|
||||
applyAppearanceToDocument('material', resolveColorScheme())
|
||||
expect(document.documentElement.classList.contains('scheme-light')).toBe(true)
|
||||
})
|
||||
|
||||
it('auto theme picks material on Android user agent', () => {
|
||||
vi.stubGlobal('navigator', {
|
||||
...navigator,
|
||||
userAgent: 'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36'
|
||||
})
|
||||
expect(resolveAppTheme()).toBe('material')
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getColorSchemePreference as getStoredColorScheme, getThemePreference } from './userPreferences.js'
|
||||
|
||||
export type ColorSchemePreference = 'auto' | 'light' | 'dark'
|
||||
export type ResolvedColorScheme = 'light' | 'dark'
|
||||
export type AppTheme = 'ocean' | 'material' | 'cupertino'
|
||||
@@ -6,7 +8,7 @@ const THEME_CLASSES = ['theme-ocean', 'theme-material', 'theme-cupertino'] as co
|
||||
const SCHEME_CLASSES = ['scheme-light', 'scheme-dark'] as const
|
||||
|
||||
export function getColorSchemePreference(): ColorSchemePreference {
|
||||
const stored = localStorage.getItem('active_color_scheme')
|
||||
const stored = getStoredColorScheme()
|
||||
if (stored === 'light' || stored === 'dark' || stored === 'auto') return stored
|
||||
return 'auto'
|
||||
}
|
||||
@@ -19,7 +21,7 @@ export function resolveColorScheme(pref?: ColorSchemePreference): ResolvedColorS
|
||||
}
|
||||
|
||||
export function resolveAppTheme(): AppTheme {
|
||||
const configTheme = localStorage.getItem('active_theme') || 'auto'
|
||||
const configTheme = getThemePreference() || 'auto'
|
||||
if (configTheme === 'material' || configTheme === 'cupertino' || configTheme === 'ocean') {
|
||||
return configTheme
|
||||
}
|
||||
@@ -29,6 +31,18 @@ export function resolveAppTheme(): AppTheme {
|
||||
return 'ocean'
|
||||
}
|
||||
|
||||
function updateThemeColorMeta(root: HTMLElement): void {
|
||||
const color = getComputedStyle(root).getPropertyValue('--app-theme-color').trim()
|
||||
if (!color) return
|
||||
let meta = document.querySelector('meta[name="theme-color"]')
|
||||
if (!meta) {
|
||||
meta = document.createElement('meta')
|
||||
meta.setAttribute('name', 'theme-color')
|
||||
document.head.appendChild(meta)
|
||||
}
|
||||
meta.setAttribute('content', color)
|
||||
}
|
||||
|
||||
export function applyAppearanceToDocument(
|
||||
theme: AppTheme = resolveAppTheme(),
|
||||
scheme: ResolvedColorScheme = resolveColorScheme()
|
||||
@@ -37,6 +51,7 @@ export function applyAppearanceToDocument(
|
||||
root.classList.remove(...THEME_CLASSES, ...SCHEME_CLASSES)
|
||||
root.classList.add(`theme-${theme}`, `scheme-${scheme}`)
|
||||
root.style.colorScheme = scheme
|
||||
updateThemeColorMeta(root)
|
||||
}
|
||||
|
||||
export function subscribeToSystemColorScheme(onChange: () => void): () => void {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
fetchAppearancePrefs,
|
||||
saveAppearancePrefsToServer,
|
||||
syncAppearancePrefs
|
||||
} from './appearancePrefs.js'
|
||||
import { setThemePreference } from './userPreferences.js'
|
||||
|
||||
const USER_ID = 'appearance-sync-user'
|
||||
|
||||
vi.mock('./api.js', () => ({
|
||||
apiJson: vi.fn()
|
||||
}))
|
||||
|
||||
import { apiJson } from './api.js'
|
||||
|
||||
const mockedApiJson = vi.mocked(apiJson)
|
||||
|
||||
describe('appearancePrefs', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('fetchAppearancePrefs returns defaults when not authenticated', async () => {
|
||||
await expect(fetchAppearancePrefs()).resolves.toEqual({
|
||||
theme: 'auto',
|
||||
colorScheme: 'auto',
|
||||
persisted: false
|
||||
})
|
||||
expect(mockedApiJson).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('syncAppearancePrefs applies server prefs after cache wipe', async () => {
|
||||
localStorage.setItem('active_userid', USER_ID)
|
||||
mockedApiJson.mockResolvedValueOnce({
|
||||
theme: 'ocean',
|
||||
colorScheme: 'dark',
|
||||
persisted: true
|
||||
})
|
||||
|
||||
const changed = vi.fn()
|
||||
window.addEventListener('appearance-changed', changed)
|
||||
|
||||
await syncAppearancePrefs(USER_ID)
|
||||
|
||||
expect(localStorage.getItem(`user_pref_theme_${USER_ID}`)).toBe('ocean')
|
||||
expect(localStorage.getItem(`user_pref_color_scheme_${USER_ID}`)).toBe('dark')
|
||||
expect(changed).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('syncAppearancePrefs uploads local prefs when server has none', async () => {
|
||||
localStorage.setItem('active_userid', USER_ID)
|
||||
setThemePreference(USER_ID, 'material')
|
||||
mockedApiJson
|
||||
.mockResolvedValueOnce({ theme: 'auto', colorScheme: 'auto', persisted: false })
|
||||
.mockResolvedValueOnce({ theme: 'material', colorScheme: 'auto', persisted: true })
|
||||
|
||||
await syncAppearancePrefs(USER_ID)
|
||||
|
||||
expect(mockedApiJson).toHaveBeenCalledTimes(2)
|
||||
expect(mockedApiJson).toHaveBeenLastCalledWith('/api/auth/appearance-prefs', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ theme: 'material', colorScheme: 'auto' })
|
||||
})
|
||||
})
|
||||
|
||||
it('saveAppearancePrefsToServer skips when not authenticated', async () => {
|
||||
await saveAppearancePrefsToServer('ocean', 'light')
|
||||
expect(mockedApiJson).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('syncAppearancePrefs skips server sync when userId does not match active session', async () => {
|
||||
localStorage.setItem('active_userid', 'session-user')
|
||||
setThemePreference('other-user', 'ocean')
|
||||
mockedApiJson.mockResolvedValue({
|
||||
theme: 'material',
|
||||
colorScheme: 'dark',
|
||||
persisted: true
|
||||
})
|
||||
|
||||
await syncAppearancePrefs('other-user')
|
||||
|
||||
expect(mockedApiJson).not.toHaveBeenCalled()
|
||||
expect(localStorage.getItem('user_pref_theme_other-user')).toBe('ocean')
|
||||
})
|
||||
|
||||
it('syncAppearancePrefs skips server sync when active session is missing', async () => {
|
||||
setThemePreference(USER_ID, 'ocean')
|
||||
|
||||
await syncAppearancePrefs(USER_ID)
|
||||
|
||||
expect(mockedApiJson).not.toHaveBeenCalled()
|
||||
expect(localStorage.getItem(`user_pref_theme_${USER_ID}`)).toBe('ocean')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import { apiJson } from './api.js'
|
||||
import { notifyAppearanceChanged } from './appearance.js'
|
||||
import {
|
||||
getActiveUserId,
|
||||
getColorSchemePreference,
|
||||
getThemePreference,
|
||||
setColorSchemePreference,
|
||||
setThemePreference
|
||||
} from './userPreferences.js'
|
||||
|
||||
const API_BASE = '/api/auth/appearance-prefs'
|
||||
|
||||
export interface AppearancePrefs {
|
||||
theme: string
|
||||
colorScheme: string
|
||||
persisted: boolean
|
||||
}
|
||||
|
||||
function hasLocalAppearancePrefs(userId: string): boolean {
|
||||
return (
|
||||
localStorage.getItem(`user_pref_theme_${userId}`) != null ||
|
||||
localStorage.getItem(`user_pref_color_scheme_${userId}`) != null
|
||||
)
|
||||
}
|
||||
|
||||
function resolveSyncedUserId(userId?: string | null): string | null {
|
||||
const id = userId?.trim() || getActiveUserId()?.trim() || null
|
||||
if (!id) return null
|
||||
|
||||
const activeId = getActiveUserId()?.trim() || null
|
||||
if (!activeId || activeId !== id) return null
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
export async function fetchAppearancePrefs(userId?: string | null): Promise<AppearancePrefs> {
|
||||
if (!resolveSyncedUserId(userId)) {
|
||||
return { theme: 'auto', colorScheme: 'auto', persisted: false }
|
||||
}
|
||||
|
||||
return apiJson<AppearancePrefs>(API_BASE)
|
||||
}
|
||||
|
||||
export async function saveAppearancePrefsToServer(
|
||||
theme: string,
|
||||
colorScheme: string,
|
||||
userId?: string | null
|
||||
): Promise<void> {
|
||||
if (!resolveSyncedUserId(userId)) return
|
||||
|
||||
await apiJson<AppearancePrefs>(API_BASE, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ theme, colorScheme })
|
||||
})
|
||||
}
|
||||
|
||||
/** Merge server-stored appearance with local cache (server wins after cache wipe). */
|
||||
export async function syncAppearancePrefs(userId?: string | null): Promise<void> {
|
||||
const id = resolveSyncedUserId(userId)
|
||||
if (!id) return
|
||||
|
||||
try {
|
||||
const server = await fetchAppearancePrefs(id)
|
||||
|
||||
if (server.persisted) {
|
||||
setThemePreference(id, server.theme)
|
||||
setColorSchemePreference(id, server.colorScheme)
|
||||
} else if (hasLocalAppearancePrefs(id)) {
|
||||
await saveAppearancePrefsToServer(getThemePreference(id), getColorSchemePreference(id), id)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to sync appearance preferences:', err)
|
||||
}
|
||||
|
||||
notifyAppearanceChanged()
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { clearLogbookKeysCache } from './logbookKeys.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
|
||||
import { db } from './db.js'
|
||||
import { apiFetch, apiJson } from './api.js'
|
||||
import { isWebAuthnUserAbortError } from '../utils/passkeyHost.js'
|
||||
|
||||
const API_BASE = '/api/auth'
|
||||
|
||||
@@ -33,10 +34,33 @@ export function setActiveMasterKey(key: ArrayBuffer | null) {
|
||||
}
|
||||
|
||||
export async function checkServerSession(): Promise<{ authenticated: boolean; userId?: string }> {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), 8_000)
|
||||
try {
|
||||
return await apiJson<{ authenticated: boolean; userId?: string }>(`${API_BASE}/session`)
|
||||
return await apiJson<{ authenticated: boolean; userId?: string }>(`${API_BASE}/session`, {
|
||||
signal: controller.signal
|
||||
})
|
||||
} catch {
|
||||
return { authenticated: false }
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
/** Master key + username in memory/storage — enough to stay in the unlocked UI. */
|
||||
export function hasUnlockedLocalCrypto(): boolean {
|
||||
return !!(getActiveMasterKey() && localStorage.getItem('active_username'))
|
||||
}
|
||||
|
||||
/** Crypto unlock plus user id for authenticated API calls (userId may already be in localStorage). */
|
||||
export function hasUnlockedLocalSession(): boolean {
|
||||
return hasUnlockedLocalCrypto() && !!localStorage.getItem('active_userid')
|
||||
}
|
||||
|
||||
/** Persist server session user id when the /session response includes it. */
|
||||
export function persistSessionUserId(userId: string | undefined): void {
|
||||
if (userId) {
|
||||
localStorage.setItem('active_userid', userId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,7 +362,11 @@ export async function loginUser(username?: string): Promise<LoginResult> {
|
||||
const prfRequested = !!options.extensions?.prf
|
||||
try {
|
||||
credentialResponse = await startAuthentication({ optionsJSON: options })
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
// User cancelled or timed out — never open a second platform prompt.
|
||||
if (isWebAuthnUserAbortError(err)) {
|
||||
throw err
|
||||
}
|
||||
if (prfRequested) {
|
||||
console.warn('Passkey authentication with PRF extension failed, retrying without PRF:', err)
|
||||
if (options.extensions) {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
hasUnlockedLocalCrypto,
|
||||
hasUnlockedLocalSession,
|
||||
setActiveMasterKey
|
||||
} from './auth.js'
|
||||
|
||||
describe('local session unlock checks', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
setActiveMasterKey(null)
|
||||
})
|
||||
|
||||
it('hasUnlockedLocalCrypto with master key and username only', () => {
|
||||
setActiveMasterKey(new ArrayBuffer(32))
|
||||
localStorage.setItem('active_username', 'skipper')
|
||||
expect(hasUnlockedLocalCrypto()).toBe(true)
|
||||
expect(hasUnlockedLocalSession()).toBe(false)
|
||||
})
|
||||
|
||||
it('hasUnlockedLocalSession when userId is present', () => {
|
||||
setActiveMasterKey(new ArrayBuffer(32))
|
||||
localStorage.setItem('active_username', 'skipper')
|
||||
localStorage.setItem('active_userid', 'user-1')
|
||||
expect(hasUnlockedLocalCrypto()).toBe(true)
|
||||
expect(hasUnlockedLocalSession()).toBe(true)
|
||||
})
|
||||
|
||||
it('hasUnlockedLocalCrypto false without master key', () => {
|
||||
localStorage.setItem('active_username', 'skipper')
|
||||
localStorage.setItem('active_userid', 'user-1')
|
||||
expect(hasUnlockedLocalCrypto()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('persistSessionUserId', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('stores userId when provided', async () => {
|
||||
const { persistSessionUserId } = await import('./auth.js')
|
||||
persistSessionUserId('user-42')
|
||||
expect(localStorage.getItem('active_userid')).toBe('user-42')
|
||||
})
|
||||
|
||||
it('does not clear existing userId when omitted', async () => {
|
||||
const { persistSessionUserId } = await import('./auth.js')
|
||||
localStorage.setItem('active_userid', 'user-1')
|
||||
persistSessionUserId(undefined)
|
||||
expect(localStorage.getItem('active_userid')).toBe('user-1')
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import { decryptJson } from './crypto.js'
|
||||
import { formatSignatureForExport, normalizeSignature } from '../utils/signatures.js'
|
||||
import { sortLogEventsByTime } from '../utils/logEntryPayload.js'
|
||||
import i18n from '../i18n/index.js'
|
||||
import { formatAppDateTime } from '../utils/dateTimeFormat.js'
|
||||
|
||||
function escapeCsvValue(val: string | number | undefined | null): string {
|
||||
if (val === null || val === undefined) return '';
|
||||
@@ -87,6 +88,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
'Latitude', 'Longitude', 'Remarks',
|
||||
'Freshwater Morning (L)', 'Freshwater Refilled (L)', 'Freshwater Evening (L)', 'Freshwater Consumption (L)',
|
||||
'Fuel Morning (L)', 'Fuel Refilled (L)', 'Fuel Evening (L)', 'Fuel Consumption (L)',
|
||||
'Greywater Level (L)',
|
||||
'Yacht Name', 'Home Port', 'Owner', 'Charter Company', 'Registration', 'Callsign', 'ATIS', 'MMSI'
|
||||
];
|
||||
|
||||
@@ -94,11 +96,11 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
const exportLabels = {
|
||||
imagePlaceholder: i18n.t('logs.sign_export_image'),
|
||||
passkeyLabel: (username: string, signedAt: string) => {
|
||||
const date = new Date(signedAt).toLocaleString(i18n.language === 'de' ? 'de-DE' : 'en-GB')
|
||||
const date = formatAppDateTime(signedAt, i18n.language)
|
||||
return i18n.t('logs.sign_passkey_export', { username, date })
|
||||
},
|
||||
attributionLabel: (username: string, signedAt: string) => {
|
||||
const date = new Date(signedAt).toLocaleString(i18n.language === 'de' ? 'de-DE' : 'en-GB')
|
||||
const date = formatAppDateTime(signedAt, i18n.language)
|
||||
return i18n.t('logs.sign_attribution_export', { username, date })
|
||||
}
|
||||
};
|
||||
@@ -122,6 +124,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
const fuelR = entry.fuel?.refilled ?? '';
|
||||
const fuelE = entry.fuel?.evening ?? '';
|
||||
const fuelCons = entry.fuel?.consumption ?? '';
|
||||
const greywaterLevel = entry.greywater?.level ?? '';
|
||||
|
||||
const eventsList = entry.events || [];
|
||||
if (eventsList.length === 0) {
|
||||
@@ -136,6 +139,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
'', '', '',
|
||||
fwM, fwR, fwE, fwCons,
|
||||
fuelM, fuelR, fuelE, fuelCons,
|
||||
greywaterLevel,
|
||||
yachtName, homePort, owner, charter, registration, callsign, atis, mmsi
|
||||
].map(escapeCsvValue));
|
||||
} else {
|
||||
@@ -152,6 +156,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
ev.gpsLat || '', ev.gpsLng || '', ev.remarks || '',
|
||||
fwM, fwR, fwE, fwCons,
|
||||
fuelM, fuelR, fuelE, fuelCons,
|
||||
greywaterLevel,
|
||||
yachtName, homePort, owner, charter, registration, callsign, atis, mmsi
|
||||
].map(escapeCsvValue));
|
||||
}
|
||||
|
||||
@@ -64,6 +64,15 @@ export interface LocalGpsTrack {
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface LocalNmeaArchive {
|
||||
entryId: string
|
||||
logbookId: string
|
||||
encryptedData: string
|
||||
iv: string
|
||||
tag: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface LocalLogbookKey {
|
||||
logbookId: string
|
||||
encryptedKey: string
|
||||
@@ -89,6 +98,7 @@ class DaagboxDatabase extends Dexie {
|
||||
entries!: Table<LocalEntry>
|
||||
photos!: Table<LocalPhoto>
|
||||
gpsTracks!: Table<LocalGpsTrack>
|
||||
nmeaArchives!: Table<LocalNmeaArchive>
|
||||
logbookKeys!: Table<LocalLogbookKey>
|
||||
syncQueue!: Table<SyncQueueItem>
|
||||
|
||||
@@ -145,6 +155,18 @@ class DaagboxDatabase extends Dexie {
|
||||
gpsTracks: 'entryId, logbookId, updatedAt',
|
||||
logbookKeys: 'logbookId'
|
||||
})
|
||||
this.version(6).stores({
|
||||
logbooks: 'id, encryptedTitle, updatedAt, isSynced, isShared, isDemo',
|
||||
yachts: 'logbookId, updatedAt',
|
||||
crews: 'payloadId, logbookId, updatedAt',
|
||||
deviations: 'logbookId, updatedAt',
|
||||
entries: 'payloadId, logbookId, updatedAt',
|
||||
syncQueue: '++id, action, type, payloadId, logbookId',
|
||||
photos: 'payloadId, entryId, logbookId, updatedAt',
|
||||
gpsTracks: 'entryId, logbookId, updatedAt',
|
||||
nmeaArchives: 'entryId, logbookId, updatedAt',
|
||||
logbookKeys: 'logbookId'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
clearDemoLogbookRefs,
|
||||
getDemoFirstEntryStorageKey,
|
||||
getDemoLogbookStorageKey
|
||||
} from './demoLogbook.js'
|
||||
|
||||
describe('clearDemoLogbookRefs', () => {
|
||||
const userId = 'user-1'
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
localStorage.setItem('active_userid', userId)
|
||||
})
|
||||
|
||||
it('removes demo logbook and first-entry keys for the user', () => {
|
||||
const logbookId = 'lb-demo'
|
||||
localStorage.setItem(getDemoLogbookStorageKey(userId), logbookId)
|
||||
localStorage.setItem(getDemoFirstEntryStorageKey(userId), 'entry-1')
|
||||
|
||||
clearDemoLogbookRefs(userId, logbookId)
|
||||
|
||||
expect(localStorage.getItem(getDemoLogbookStorageKey(userId))).toBeNull()
|
||||
expect(localStorage.getItem(getDemoFirstEntryStorageKey(userId))).toBeNull()
|
||||
})
|
||||
|
||||
it('does not clear refs when logbookId does not match stored demo id', () => {
|
||||
localStorage.setItem(getDemoLogbookStorageKey(userId), 'other-logbook')
|
||||
localStorage.setItem(getDemoFirstEntryStorageKey(userId), 'entry-1')
|
||||
|
||||
clearDemoLogbookRefs(userId, 'deleted-logbook')
|
||||
|
||||
expect(localStorage.getItem(getDemoLogbookStorageKey(userId))).toBe('other-logbook')
|
||||
expect(localStorage.getItem(getDemoFirstEntryStorageKey(userId))).toBe('entry-1')
|
||||
})
|
||||
})
|
||||
@@ -108,6 +108,7 @@ export async function seedDemoLogbookIfNeeded(): Promise<DemoSeedResult | null>
|
||||
const title = i18n.t('demo.logbook_title')
|
||||
return { logbookId: existingId, title, firstEntryId }
|
||||
}
|
||||
clearDemoLogbookRefs(userId, existingId)
|
||||
}
|
||||
|
||||
if (!shouldSeed) return null
|
||||
@@ -152,3 +153,66 @@ export function getStoredDemoFirstEntryId(): string | null {
|
||||
if (!userId) return null
|
||||
return localStorage.getItem(getDemoFirstEntryStorageKey(userId))
|
||||
}
|
||||
|
||||
/** Remove persisted demo logbook pointers when the logbook no longer exists. */
|
||||
export function clearDemoLogbookRefs(userId: string, logbookId?: string): void {
|
||||
const storedId = localStorage.getItem(getDemoLogbookStorageKey(userId))
|
||||
if (logbookId && storedId && storedId !== logbookId) return
|
||||
localStorage.removeItem(getDemoLogbookStorageKey(userId))
|
||||
localStorage.removeItem(getDemoFirstEntryStorageKey(userId))
|
||||
}
|
||||
|
||||
export async function entryExistsInLogbook(logbookId: string, entryId: string): Promise<boolean> {
|
||||
const entry = await db.entries.get(entryId)
|
||||
return entry?.logbookId === logbookId
|
||||
}
|
||||
|
||||
export interface TourLogbookContext {
|
||||
logbookId: string
|
||||
title: string
|
||||
firstEntryId: string | null
|
||||
}
|
||||
|
||||
/** Pick a logbook + first entry for the onboarding tour (handles deleted demo data). */
|
||||
export async function resolveTourLogbookContext(
|
||||
preferLogbookId?: string | null
|
||||
): Promise<TourLogbookContext | null> {
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId || !getActiveMasterKey()) return null
|
||||
|
||||
const demoId = localStorage.getItem(getDemoLogbookStorageKey(userId))
|
||||
if (demoId && !(await db.logbooks.get(demoId))) {
|
||||
clearDemoLogbookRefs(userId, demoId)
|
||||
}
|
||||
|
||||
const { fetchLogbooks } = await import('./logbook.js')
|
||||
const books = await fetchLogbooks()
|
||||
if (books.length === 0) return null
|
||||
|
||||
const activeId = localStorage.getItem('active_logbook_id')
|
||||
const pick =
|
||||
(preferLogbookId ? books.find((b) => b.id === preferLogbookId) : undefined) ??
|
||||
(activeId ? books.find((b) => b.id === activeId) : undefined) ??
|
||||
(demoId ? books.find((b) => b.id === demoId) : undefined) ??
|
||||
books[0]
|
||||
|
||||
const firstEntryId = await resolveTourFirstEntryId(pick.id, userId)
|
||||
return { logbookId: pick.id, title: pick.title, firstEntryId }
|
||||
}
|
||||
|
||||
async function resolveTourFirstEntryId(logbookId: string, userId: string): Promise<string | null> {
|
||||
const stored = localStorage.getItem(getDemoFirstEntryStorageKey(userId))
|
||||
if (stored && (await entryExistsInLogbook(logbookId, stored))) {
|
||||
return stored
|
||||
}
|
||||
|
||||
if (stored) {
|
||||
localStorage.removeItem(getDemoFirstEntryStorageKey(userId))
|
||||
}
|
||||
|
||||
const localEntries = await db.entries.where({ logbookId }).toArray()
|
||||
if (localEntries.length === 0) return null
|
||||
|
||||
localEntries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
||||
return localEntries[0]?.payloadId ?? null
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { parseTrackFile } from './trackUpload.js'
|
||||
import { computeTrackStats } from '../utils/trackStats.js'
|
||||
import i18n from '../i18n/index.js'
|
||||
import { isGermanLocale } from '../utils/i18nLanguages.js'
|
||||
|
||||
import kielLaboeGpx from '../assets/demo/kiel-laboe.gpx?raw'
|
||||
import laboeDampGpx from '../assets/demo/laboe-damp.gpx?raw'
|
||||
@@ -26,6 +27,7 @@ export interface DemoDaySpec {
|
||||
filename: string
|
||||
freshwater: { morning: number; refilled: number; evening: number; consumption: number }
|
||||
fuel: { morning: number; refilled: number; evening: number; consumption: number }
|
||||
greywaterLevel?: number
|
||||
motorHours?: number
|
||||
events: Array<Record<string, string>>
|
||||
}
|
||||
@@ -58,7 +60,7 @@ export interface PublicDemoFixture {
|
||||
}
|
||||
|
||||
export function buildDemoDays(): DemoDaySpec[] {
|
||||
const isDe = i18n.language.startsWith('de')
|
||||
const isDe = isGermanLocale(i18n.language)
|
||||
return [
|
||||
{
|
||||
date: '2026-05-29',
|
||||
@@ -69,6 +71,7 @@ export function buildDemoDays(): DemoDaySpec[] {
|
||||
filename: 'kiel-laboe.gpx',
|
||||
freshwater: { morning: 120, refilled: 0, evening: 105, consumption: 15 },
|
||||
fuel: { morning: 85, refilled: 0, evening: 78, consumption: 7 },
|
||||
greywaterLevel: 25,
|
||||
events: [
|
||||
{
|
||||
time: '10:15',
|
||||
@@ -101,6 +104,7 @@ export function buildDemoDays(): DemoDaySpec[] {
|
||||
filename: 'laboe-damp.gpx',
|
||||
freshwater: { morning: 105, refilled: 25, evening: 110, consumption: 20 },
|
||||
fuel: { morning: 78, refilled: 0, evening: 70, consumption: 8 },
|
||||
greywaterLevel: 38,
|
||||
motorHours: 1.5,
|
||||
events: [
|
||||
{
|
||||
@@ -134,6 +138,7 @@ export function buildDemoDays(): DemoDaySpec[] {
|
||||
filename: 'damp-schleimuende.gpx',
|
||||
freshwater: { morning: 110, refilled: 0, evening: 95, consumption: 15 },
|
||||
fuel: { morning: 70, refilled: 15, evening: 80, consumption: 5 },
|
||||
greywaterLevel: 52,
|
||||
events: [
|
||||
{
|
||||
time: '08:30',
|
||||
@@ -161,7 +166,7 @@ export function buildDemoDays(): DemoDaySpec[] {
|
||||
}
|
||||
|
||||
export function buildDemoYachtData(): Record<string, unknown> {
|
||||
const isDe = i18n.language.startsWith('de')
|
||||
const isDe = isGermanLocale(i18n.language)
|
||||
return {
|
||||
name: 'Seeadler',
|
||||
vesselType: isDe ? 'Segelyacht' : 'Sailing yacht',
|
||||
@@ -176,12 +181,15 @@ export function buildDemoYachtData(): Record<string, unknown> {
|
||||
atis: '',
|
||||
mmsi: '',
|
||||
sails: isDe ? ['Großsegel', 'Genua', 'Spinnaker'] : ['Mainsail', 'Genoa', 'Spinnaker'],
|
||||
photo: null
|
||||
photo: null,
|
||||
freshwaterCapacityL: 200,
|
||||
fuelCapacityL: 100,
|
||||
greywaterCapacityL: 80
|
||||
}
|
||||
}
|
||||
|
||||
export function buildDemoCrewRecords(): DemoCrewRecord[] {
|
||||
const isDe = i18n.language.startsWith('de')
|
||||
const isDe = isGermanLocale(i18n.language)
|
||||
return [
|
||||
{
|
||||
payloadId: 'skipper',
|
||||
@@ -244,6 +252,10 @@ export function buildPublicDemoFixture(): PublicDemoFixture {
|
||||
events: day.events
|
||||
}
|
||||
|
||||
if (day.greywaterLevel != null && day.greywaterLevel > 0) {
|
||||
entryPayload.greywater = { level: day.greywaterLevel }
|
||||
}
|
||||
|
||||
if (stats) {
|
||||
entryPayload.trackDistanceNm = stats.distanceNm
|
||||
entryPayload.trackSpeedMaxKn = stats.speedMaxKn
|
||||
@@ -303,6 +315,10 @@ export function buildDemoEntryPayloads(): Array<{
|
||||
events: day.events
|
||||
}
|
||||
|
||||
if (day.greywaterLevel != null && day.greywaterLevel > 0) {
|
||||
entryPayload.greywater = { level: day.greywaterLevel }
|
||||
}
|
||||
|
||||
if (stats) {
|
||||
entryPayload.trackDistanceNm = stats.distanceNm
|
||||
entryPayload.trackSpeedMaxKn = stats.speedMaxKn
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { getLogbookKey } from './logbookKeys.js'
|
||||
import { decryptJson } from './crypto.js'
|
||||
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
|
||||
import type { LogEventPayload } from '../utils/logEntryPayload.js'
|
||||
import { LIVE_EVENT_CODES } from '../utils/liveEventCodes.js'
|
||||
|
||||
export interface EventSeriesPoint {
|
||||
entryId: string
|
||||
date: string
|
||||
dayOfTravel: string
|
||||
time: string
|
||||
summary: string
|
||||
}
|
||||
|
||||
export interface EventSeriesSummary {
|
||||
pressure: EventSeriesPoint[]
|
||||
wind: EventSeriesPoint[]
|
||||
motor: EventSeriesPoint[]
|
||||
}
|
||||
|
||||
function sortPoints(points: EventSeriesPoint[]): EventSeriesPoint[] {
|
||||
return [...points].sort((a, b) => {
|
||||
const dateCompare = a.date.localeCompare(b.date)
|
||||
if (dateCompare !== 0) return dateCompare
|
||||
return a.time.localeCompare(b.time)
|
||||
})
|
||||
}
|
||||
|
||||
export async function loadLogbookEventSeries(logbookId: string): Promise<EventSeriesSummary> {
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
|
||||
const local = await db.entries.where({ logbookId }).toArray()
|
||||
const decryptedEntries: Array<{
|
||||
entryId: string
|
||||
date: string
|
||||
dayOfTravel: string
|
||||
events: LogEventPayload[]
|
||||
}> = []
|
||||
|
||||
for (const entry of local) {
|
||||
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
|
||||
if (!decrypted) continue
|
||||
decryptedEntries.push({
|
||||
entryId: entry.payloadId,
|
||||
date: String(decrypted.date || ''),
|
||||
dayOfTravel: String(decrypted.dayOfTravel || ''),
|
||||
events: (decrypted.events as LogEventPayload[]) || []
|
||||
})
|
||||
}
|
||||
|
||||
decryptedEntries.sort((a, b) =>
|
||||
compareTravelDaysChronological(
|
||||
{ date: a.date, dayOfTravel: a.dayOfTravel },
|
||||
{ date: b.date, dayOfTravel: b.dayOfTravel }
|
||||
)
|
||||
)
|
||||
|
||||
const pressure: EventSeriesPoint[] = []
|
||||
const wind: EventSeriesPoint[] = []
|
||||
const motor: EventSeriesPoint[] = []
|
||||
|
||||
for (const entry of decryptedEntries) {
|
||||
for (const event of entry.events) {
|
||||
const base = {
|
||||
entryId: entry.entryId,
|
||||
date: entry.date,
|
||||
dayOfTravel: entry.dayOfTravel,
|
||||
time: event.time
|
||||
}
|
||||
|
||||
if (event.windPressure?.trim()) {
|
||||
pressure.push({
|
||||
...base,
|
||||
summary: `${event.windPressure} hPa`
|
||||
})
|
||||
}
|
||||
|
||||
if (event.windDirection?.trim() || event.windStrength?.trim()) {
|
||||
wind.push({
|
||||
...base,
|
||||
summary: [event.windDirection, event.windStrength].filter(Boolean).join(' ')
|
||||
})
|
||||
}
|
||||
|
||||
const code = event.remarks?.trim() ?? ''
|
||||
if (
|
||||
code === LIVE_EVENT_CODES.MOTOR_START ||
|
||||
code === LIVE_EVENT_CODES.MOTOR_STOP
|
||||
) {
|
||||
motor.push({
|
||||
...base,
|
||||
summary: code === LIVE_EVENT_CODES.MOTOR_START ? 'start' : 'stop'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
pressure: sortPoints(pressure),
|
||||
wind: sortPoints(wind),
|
||||
motor: sortPoints(motor)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { apiFetch } from './api.js'
|
||||
|
||||
export type FeedbackCategory = 'bug' | 'feature' | 'general'
|
||||
export type FeedbackCategory = 'bug' | 'feature' | 'general' | 'translation'
|
||||
|
||||
export class FeedbackApiError extends Error {
|
||||
code: 'NOT_CONFIGURED' | 'REQUEST_FAILED' | 'INVALID_EMAIL' | 'RATE_LIMITED' | 'SPAM_DETECTED'
|
||||
|
||||
@@ -4,6 +4,7 @@ import { encryptJson, decryptJson, encryptBuffer, decryptBuffer } from './crypto
|
||||
import { getLogbookKey, saveLogbookKey, generateLogbookKey } from './logbookKeys.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
|
||||
import { apiFetch } from './api.js'
|
||||
import { clearDemoLogbookRefs, getDemoLogbookStorageKey } from './demoLogbook.js'
|
||||
|
||||
const API_BASE = '/api/logbooks'
|
||||
|
||||
@@ -213,6 +214,10 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
|
||||
|
||||
if (response.ok) {
|
||||
const serverLb = await response.json()
|
||||
if (serverLb.id !== localId) {
|
||||
await saveLogbookKey(serverLb.id, logbookKey)
|
||||
await db.logbookKeys.delete(localId)
|
||||
}
|
||||
await db.logbooks.put({
|
||||
id: serverLb.id,
|
||||
encryptedTitle: serverLb.encryptedTitle,
|
||||
@@ -320,6 +325,9 @@ export async function deleteLogbook(id: string): Promise<void> {
|
||||
|
||||
// Perform local cascading cleanup
|
||||
await deleteLocalLogbookCache(id)
|
||||
if (userId && id === localStorage.getItem(getDemoLogbookStorageKey(userId))) {
|
||||
clearDemoLogbookRefs(userId, id)
|
||||
}
|
||||
trackPlausibleEvent(PlausibleEvents.LOGBOOK_DELETED)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseNmeaFile } from './nmeaParse.js'
|
||||
import { detectNmeaChanges } from './nmeaChangeDetection.js'
|
||||
import { generateNmeaJournalCandidates } from './nmeaJournalGenerator.js'
|
||||
|
||||
const nmeaPath = resolve(import.meta.dirname, '../../../../testdata/tracks/kieler-foerde-5sm.nmea')
|
||||
|
||||
describe('kieler-foerde testdata', () => {
|
||||
it('parses the sample NMEA log and yields journal candidates', () => {
|
||||
const text = readFileSync(nmeaPath, 'utf8')
|
||||
const result = parseNmeaFile(text, 'kieler-foerde-5sm.nmea')
|
||||
|
||||
expect(result.stats.checksumErrors).toBe(0)
|
||||
expect(result.points.length).toBeGreaterThan(30)
|
||||
expect(result.stats.sentenceTypes).toEqual(expect.arrayContaining(['RMC', 'GGA', 'MWV', 'DPT', 'MDA']))
|
||||
|
||||
const changes = detectNmeaChanges(result.points)
|
||||
expect(changes.length).toBeGreaterThan(0)
|
||||
expect(changes.some((c) => ['wind', 'engine_start', 'departure', 'speed', 'depth'].includes(c.type))).toBe(true)
|
||||
|
||||
const journal = generateNmeaJournalCandidates({
|
||||
points: result.points,
|
||||
mode: 'both',
|
||||
intervalMinutes: 60,
|
||||
t: (key) => key
|
||||
})
|
||||
expect(journal.candidates.length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { NmeaTimePoint } from './nmeaTypes.js'
|
||||
import { detectNmeaChanges } from './nmeaChangeDetection.js'
|
||||
|
||||
function point(
|
||||
timestamp: number,
|
||||
overrides: Partial<NmeaTimePoint> = {}
|
||||
): NmeaTimePoint {
|
||||
return { timestamp, ...overrides }
|
||||
}
|
||||
|
||||
describe('detectNmeaChanges', () => {
|
||||
it('detects significant course changes while underway', () => {
|
||||
const points = [
|
||||
point(0, { cog: 0, sog: 5 }),
|
||||
point(60_000, { cog: 45, sog: 5 })
|
||||
]
|
||||
|
||||
const events = detectNmeaChanges(points, {
|
||||
courseDeltaDeg: 30,
|
||||
windDirDeltaDeg: 30,
|
||||
windSpeedDeltaKnots: 5,
|
||||
pressureDeltaHpa: 2,
|
||||
depthDeltaM: 1,
|
||||
depthDeltaPercent: 25,
|
||||
rpmIdle: 400,
|
||||
rpmRunning: 800,
|
||||
sogUnderWayKn: 2,
|
||||
sogStoppedKn: 0.5,
|
||||
anchorMinutes: 10,
|
||||
speedDeltaKn: 2,
|
||||
dedupeWindowMs: 60_000
|
||||
})
|
||||
|
||||
expect(events.some((e) => e.type === 'course')).toBe(true)
|
||||
const course = events.find((e) => e.type === 'course')
|
||||
expect(course?.summaryParams).toMatchObject({ from: 0, to: 45 })
|
||||
})
|
||||
|
||||
it('detects engine start when RPM rises above threshold', () => {
|
||||
const points = [
|
||||
point(0, { sog: 0, rpm: 0 }),
|
||||
point(30_000, { sog: 3, rpm: 1200 })
|
||||
]
|
||||
|
||||
const events = detectNmeaChanges(points)
|
||||
expect(events.some((e) => e.type === 'engine_start')).toBe(true)
|
||||
})
|
||||
|
||||
it('dedupes repeated events within the configured window', () => {
|
||||
const points = [
|
||||
point(0, { cog: 0, sog: 5 }),
|
||||
point(10_000, { cog: 50, sog: 5 }),
|
||||
point(20_000, { cog: 100, sog: 5 })
|
||||
]
|
||||
|
||||
const events = detectNmeaChanges(points, {
|
||||
courseDeltaDeg: 30,
|
||||
windDirDeltaDeg: 30,
|
||||
windSpeedDeltaKnots: 5,
|
||||
pressureDeltaHpa: 2,
|
||||
depthDeltaM: 1,
|
||||
depthDeltaPercent: 25,
|
||||
rpmIdle: 400,
|
||||
rpmRunning: 800,
|
||||
sogUnderWayKn: 2,
|
||||
sogStoppedKn: 0.5,
|
||||
anchorMinutes: 10,
|
||||
speedDeltaKn: 2,
|
||||
dedupeWindowMs: 120_000
|
||||
})
|
||||
|
||||
const courseEvents = events.filter((e) => e.type === 'course')
|
||||
expect(courseEvents.length).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,211 @@
|
||||
import type { NmeaChangeEvent, NmeaDetectionConfig, NmeaTimePoint } from './nmeaTypes.js'
|
||||
import { DEFAULT_NMEA_DETECTION_CONFIG } from './nmeaTypes.js'
|
||||
import { angularDelta } from './nmeaTimeSeries.js'
|
||||
|
||||
function pushUnique(events: NmeaChangeEvent[], event: NmeaChangeEvent, minGapMs: number) {
|
||||
const last = events[events.length - 1]
|
||||
if (last && last.type === event.type && event.timestamp - last.timestamp < minGapMs) return
|
||||
events.push(event)
|
||||
}
|
||||
|
||||
export function detectNmeaChanges(
|
||||
points: NmeaTimePoint[],
|
||||
config: NmeaDetectionConfig = DEFAULT_NMEA_DETECTION_CONFIG
|
||||
): NmeaChangeEvent[] {
|
||||
const events: NmeaChangeEvent[] = []
|
||||
if (points.length < 2) return events
|
||||
|
||||
let lastCourse: number | undefined
|
||||
let lastWindDir: number | undefined
|
||||
let lastWindSpeed: number | undefined
|
||||
let lastPressure: number | undefined
|
||||
let lastDepth: number | undefined
|
||||
let lastWaterTemp: number | undefined
|
||||
let lastFix: boolean | undefined
|
||||
let engineRunning = false
|
||||
let autopilot: boolean | undefined
|
||||
let underWay = false
|
||||
let stoppedSince: number | null = null
|
||||
let lastSog: number | undefined
|
||||
|
||||
for (const p of points) {
|
||||
const course = p.cog ?? p.hdt ?? p.hdm
|
||||
if (course != null && lastCourse != null && (p.sog ?? 0) > 1) {
|
||||
if (angularDelta(course, lastCourse) >= config.courseDeltaDeg) {
|
||||
pushUnique(events, {
|
||||
type: 'course',
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'high',
|
||||
summaryKey: 'logs.nmea_change_course',
|
||||
summaryParams: { from: Math.round(lastCourse), to: Math.round(course) },
|
||||
data: p
|
||||
}, config.dedupeWindowMs)
|
||||
}
|
||||
}
|
||||
if (course != null) lastCourse = course
|
||||
|
||||
if (p.windDir != null && lastWindDir != null) {
|
||||
if (angularDelta(p.windDir, lastWindDir) >= config.windDirDeltaDeg) {
|
||||
pushUnique(events, {
|
||||
type: 'wind',
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'high',
|
||||
summaryKey: 'logs.nmea_change_wind',
|
||||
summaryParams: { from: Math.round(lastWindDir), to: Math.round(p.windDir) },
|
||||
data: p
|
||||
}, config.dedupeWindowMs)
|
||||
} else if (
|
||||
p.windSpeedKnots != null &&
|
||||
lastWindSpeed != null &&
|
||||
Math.abs(p.windSpeedKnots - lastWindSpeed) >= config.windSpeedDeltaKnots
|
||||
) {
|
||||
pushUnique(events, {
|
||||
type: 'wind',
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'medium',
|
||||
summaryKey: 'logs.nmea_change_wind_speed',
|
||||
summaryParams: { from: lastWindSpeed.toFixed(1), to: p.windSpeedKnots.toFixed(1) },
|
||||
data: p
|
||||
}, config.dedupeWindowMs)
|
||||
}
|
||||
}
|
||||
if (p.windDir != null) lastWindDir = p.windDir
|
||||
if (p.windSpeedKnots != null) lastWindSpeed = p.windSpeedKnots
|
||||
|
||||
if (p.pressureHpa != null && lastPressure != null) {
|
||||
if (Math.abs(p.pressureHpa - lastPressure) >= config.pressureDeltaHpa) {
|
||||
pushUnique(events, {
|
||||
type: 'pressure',
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'medium',
|
||||
summaryKey: 'logs.nmea_change_pressure',
|
||||
summaryParams: { from: lastPressure.toFixed(1), to: p.pressureHpa.toFixed(1) },
|
||||
data: p
|
||||
}, config.dedupeWindowMs)
|
||||
}
|
||||
}
|
||||
if (p.pressureHpa != null) lastPressure = p.pressureHpa
|
||||
|
||||
if (p.depthM != null && lastDepth != null) {
|
||||
const delta = Math.abs(p.depthM - lastDepth)
|
||||
const rel = lastDepth > 0 ? (delta / lastDepth) * 100 : 100
|
||||
if (delta >= config.depthDeltaM || rel >= config.depthDeltaPercent) {
|
||||
pushUnique(events, {
|
||||
type: 'depth',
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'high',
|
||||
summaryKey: 'logs.nmea_change_depth',
|
||||
summaryParams: { from: lastDepth.toFixed(1), to: p.depthM.toFixed(1) },
|
||||
data: p
|
||||
}, config.dedupeWindowMs)
|
||||
}
|
||||
}
|
||||
if (p.depthM != null) lastDepth = p.depthM
|
||||
|
||||
if (p.rpm != null) {
|
||||
const running = p.rpm >= config.rpmRunning
|
||||
const idle = p.rpm <= config.rpmIdle
|
||||
if (running && !engineRunning) {
|
||||
pushUnique(events, {
|
||||
type: 'engine_start',
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'high',
|
||||
summaryKey: 'logs.nmea_change_engine_start',
|
||||
summaryParams: { rpm: Math.round(p.rpm) },
|
||||
data: p
|
||||
}, config.dedupeWindowMs)
|
||||
engineRunning = true
|
||||
} else if (idle && engineRunning) {
|
||||
pushUnique(events, {
|
||||
type: 'engine_stop',
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'high',
|
||||
summaryKey: 'logs.nmea_change_engine_stop',
|
||||
data: p
|
||||
}, config.dedupeWindowMs)
|
||||
engineRunning = false
|
||||
}
|
||||
}
|
||||
|
||||
if (p.autopilotEngaged != null && autopilot != null && p.autopilotEngaged !== autopilot) {
|
||||
pushUnique(events, {
|
||||
type: p.autopilotEngaged ? 'autopilot_on' : 'autopilot_off',
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'high',
|
||||
summaryKey: p.autopilotEngaged ? 'logs.nmea_change_autopilot_on' : 'logs.nmea_change_autopilot_off',
|
||||
data: p
|
||||
}, config.dedupeWindowMs)
|
||||
}
|
||||
if (p.autopilotEngaged != null) autopilot = p.autopilotEngaged
|
||||
|
||||
if (p.fixValid != null && lastFix != null && p.fixValid !== lastFix) {
|
||||
pushUnique(events, {
|
||||
type: p.fixValid ? 'gps_fix_regained' : 'gps_fix_lost',
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'high',
|
||||
summaryKey: p.fixValid ? 'logs.nmea_change_gps_regained' : 'logs.nmea_change_gps_lost',
|
||||
data: p
|
||||
}, config.dedupeWindowMs)
|
||||
}
|
||||
if (p.fixValid != null) lastFix = p.fixValid
|
||||
|
||||
if (p.waterTempC != null && lastWaterTemp != null) {
|
||||
if (Math.abs(p.waterTempC - lastWaterTemp) >= 2) {
|
||||
pushUnique(events, {
|
||||
type: 'water_temp',
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'medium',
|
||||
summaryKey: 'logs.nmea_change_water_temp',
|
||||
summaryParams: { from: lastWaterTemp.toFixed(1), to: p.waterTempC.toFixed(1) },
|
||||
data: p
|
||||
}, config.dedupeWindowMs)
|
||||
}
|
||||
}
|
||||
if (p.waterTempC != null) lastWaterTemp = p.waterTempC
|
||||
|
||||
const sog = p.sog ?? 0
|
||||
if (sog >= config.sogUnderWayKn && !underWay) {
|
||||
if (stoppedSince != null && p.timestamp - stoppedSince >= config.anchorMinutes * 60 * 1000) {
|
||||
pushUnique(events, {
|
||||
type: 'departure',
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'medium',
|
||||
summaryKey: 'logs.nmea_change_departure',
|
||||
data: p
|
||||
}, config.dedupeWindowMs)
|
||||
}
|
||||
underWay = true
|
||||
stoppedSince = null
|
||||
}
|
||||
if (sog <= config.sogStoppedKn && underWay) {
|
||||
underWay = false
|
||||
stoppedSince = p.timestamp
|
||||
}
|
||||
if (sog <= config.sogStoppedKn && stoppedSince != null && !underWay) {
|
||||
if (p.timestamp - stoppedSince >= config.anchorMinutes * 60 * 1000) {
|
||||
pushUnique(events, {
|
||||
type: 'anchor',
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'medium',
|
||||
summaryKey: 'logs.nmea_change_anchor',
|
||||
data: p
|
||||
}, config.dedupeWindowMs)
|
||||
stoppedSince = null
|
||||
}
|
||||
}
|
||||
|
||||
if (lastSog != null && Math.abs(sog - lastSog) >= config.speedDeltaKn) {
|
||||
pushUnique(events, {
|
||||
type: 'speed',
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'low',
|
||||
summaryKey: 'logs.nmea_change_speed',
|
||||
summaryParams: { from: lastSog.toFixed(1), to: sog.toFixed(1) },
|
||||
data: p
|
||||
}, config.dedupeWindowMs)
|
||||
}
|
||||
lastSog = sog
|
||||
}
|
||||
|
||||
return events.sort((a, b) => a.timestamp - b.timestamp)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { TFunction } from 'i18next'
|
||||
import type { LogEventPayload } from '../../utils/logEntryPayload.js'
|
||||
import { normalizeLogEvent } from '../../utils/logEntryPayload.js'
|
||||
import { formatCourseAngle } from '../../utils/courseAngle.js'
|
||||
import { degreesToCardinal } from '../../utils/courseAngle.js'
|
||||
import type {
|
||||
NmeaChangeEvent,
|
||||
NmeaImportMode,
|
||||
NmeaJournalCandidate,
|
||||
NmeaTimePoint
|
||||
} from './nmeaTypes.js'
|
||||
import { detectNmeaChanges } from './nmeaChangeDetection.js'
|
||||
import { intervalTimestamps, sampleAt, timestampToHHMM } from './nmeaTimeSeries.js'
|
||||
|
||||
export interface GeneratedNmeaJournal {
|
||||
candidates: Array<NmeaJournalCandidate & { event: LogEventPayload }>
|
||||
}
|
||||
|
||||
function pointToLogEvent(
|
||||
point: NmeaTimePoint,
|
||||
remarks: string,
|
||||
sailsOrMotor: string
|
||||
): LogEventPayload {
|
||||
const course = point.cog ?? point.hdt ?? point.hdm
|
||||
const mgk = course != null ? formatCourseAngle(course) : ''
|
||||
const windDir =
|
||||
point.windDir != null ? degreesToCardinal(point.windDir) : ''
|
||||
|
||||
return normalizeLogEvent({
|
||||
time: timestampToHHMM(point.timestamp),
|
||||
mgk,
|
||||
rwk: '',
|
||||
windDirection: windDir,
|
||||
windStrength: point.windSpeedKnots != null ? String(point.windSpeedKnots) : '',
|
||||
windPressure: point.pressureHpa != null ? String(Math.round(point.pressureHpa)) : '',
|
||||
gpsLat: point.lat != null ? point.lat.toFixed(6) : '',
|
||||
gpsLng: point.lng != null ? point.lng.toFixed(6) : '',
|
||||
logReading: point.logDistanceNm != null ? point.logDistanceNm.toFixed(2) : '',
|
||||
sailsOrMotor,
|
||||
remarks
|
||||
})
|
||||
}
|
||||
|
||||
function changeToSailsOrMotor(type: NmeaChangeEvent['type']): string {
|
||||
if (type === 'engine_start') return 'Motor'
|
||||
if (type === 'engine_stop') return 'Segel'
|
||||
return ''
|
||||
}
|
||||
|
||||
function buildRemarks(change: NmeaChangeEvent, t: TFunction): string {
|
||||
const parts: string[] = []
|
||||
parts.push(t(change.summaryKey, change.summaryParams ?? {}))
|
||||
if (change.data?.depthM != null) {
|
||||
parts.push(t('logs.nmea_remark_depth', { depth: change.data.depthM.toFixed(1) }))
|
||||
}
|
||||
if (change.confidence === 'low') {
|
||||
parts.push(t('logs.nmea_remark_uncertain'))
|
||||
}
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
function dedupeCandidates(
|
||||
items: Array<NmeaJournalCandidate & { event: LogEventPayload; timestamp: number }>,
|
||||
windowMs: number
|
||||
): Array<NmeaJournalCandidate & { event: LogEventPayload }> {
|
||||
const sorted = [...items].sort((a, b) => a.timestamp - b.timestamp)
|
||||
const kept: typeof sorted = []
|
||||
|
||||
for (const item of sorted) {
|
||||
const near = kept.find((k) => Math.abs(k.timestamp - item.timestamp) <= windowMs)
|
||||
if (!near) {
|
||||
kept.push(item)
|
||||
continue
|
||||
}
|
||||
if (item.source === 'change' && near.source === 'interval') {
|
||||
const idx = kept.indexOf(near)
|
||||
kept[idx] = {
|
||||
...item,
|
||||
event: {
|
||||
...near.event,
|
||||
remarks: [item.event.remarks, near.event.remarks].filter(Boolean).join(' · ')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return kept
|
||||
}
|
||||
|
||||
export function generateNmeaJournalCandidates(options: {
|
||||
points: NmeaTimePoint[]
|
||||
mode: NmeaImportMode
|
||||
intervalMinutes: number
|
||||
t: TFunction
|
||||
}): GeneratedNmeaJournal {
|
||||
const { points, mode, intervalMinutes, t } = options
|
||||
const items: Array<NmeaJournalCandidate & { event: LogEventPayload; timestamp: number }> = []
|
||||
|
||||
if (mode === 'interval' || mode === 'both') {
|
||||
for (const ts of intervalTimestamps(points, intervalMinutes)) {
|
||||
const sample = sampleAt(points, ts)
|
||||
if (!sample) continue
|
||||
items.push({
|
||||
id: `interval-${ts}`,
|
||||
timestamp: ts,
|
||||
source: 'interval',
|
||||
selected: true,
|
||||
event: pointToLogEvent(sample, t('logs.nmea_remark_interval'), '')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === 'change' || mode === 'both') {
|
||||
const changes = detectNmeaChanges(points)
|
||||
for (const change of changes) {
|
||||
const sample = change.data ?? sampleAt(points, change.timestamp)
|
||||
if (!sample) continue
|
||||
items.push({
|
||||
id: `change-${change.type}-${change.timestamp}`,
|
||||
timestamp: change.timestamp,
|
||||
source: 'change',
|
||||
changeType: change.type,
|
||||
confidence: change.confidence,
|
||||
selected: true,
|
||||
event: pointToLogEvent(
|
||||
{ ...sample, timestamp: change.timestamp },
|
||||
buildRemarks(change, t),
|
||||
changeToSailsOrMotor(change.type)
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const deduped = mode === 'both'
|
||||
? dedupeCandidates(items, 15 * 60 * 1000)
|
||||
: items
|
||||
|
||||
return { candidates: deduped }
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { nmeaPointsToWaypoints, parseNmeaFile } from './nmeaParse.js'
|
||||
|
||||
describe('parseNmeaFile', () => {
|
||||
it('parses RMC position, course and speed', () => {
|
||||
const text = [
|
||||
'$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W',
|
||||
'$GPRMC,133519,A,4808.038,N,01132.000,E,025.0,090.0,230394,003.1,W'
|
||||
].join('\n')
|
||||
|
||||
const result = parseNmeaFile(text, 'test.nmea')
|
||||
|
||||
expect(result.stats.parsedLines).toBe(2)
|
||||
expect(result.stats.sentenceTypes).toContain('RMC')
|
||||
expect(result.points.length).toBeGreaterThanOrEqual(2)
|
||||
|
||||
const first = result.points[0]
|
||||
expect(first.lat).toBeCloseTo(48.1173, 3)
|
||||
expect(first.lng).toBeCloseTo(11.516667, 3)
|
||||
expect(first.sog).toBe(22.4)
|
||||
expect(first.cog).toBe(84.4)
|
||||
expect(first.fixValid).toBe(true)
|
||||
})
|
||||
|
||||
it('merges wind and depth sentences onto the same timestamp', () => {
|
||||
const text = [
|
||||
'$GPRMC,100000,A,5400.000,N,01000.000,E,5.0,180.0,010124,003.0,E',
|
||||
'$IIMWV,270.0,R,12.5,N,A',
|
||||
'$SDDPT,4.5,0.0'
|
||||
].join('\n')
|
||||
|
||||
const result = parseNmeaFile(text, 'merged.nmea')
|
||||
const last = result.points[result.points.length - 1]
|
||||
|
||||
expect(last.windDir).toBe(270)
|
||||
expect(last.windSpeedKnots).toBe(12.5)
|
||||
expect(last.depthM).toBe(4.5)
|
||||
})
|
||||
|
||||
it('skips lines with invalid checksum', () => {
|
||||
const text = '$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*FF'
|
||||
const result = parseNmeaFile(text, 'bad.nmea')
|
||||
|
||||
expect(result.stats.checksumErrors).toBe(1)
|
||||
expect(result.points).toHaveLength(0)
|
||||
expect(result.warnings).toContain('no_samples')
|
||||
})
|
||||
|
||||
it('warns when no position sentences are present', () => {
|
||||
const text = '$IIMWV,090.0,R,8.0,N,A'
|
||||
const result = parseNmeaFile(text, 'wind-only.nmea')
|
||||
|
||||
expect(result.warnings).toContain('no_position')
|
||||
})
|
||||
})
|
||||
|
||||
describe('nmeaPointsToWaypoints', () => {
|
||||
it('maps points with coordinates to track waypoints', () => {
|
||||
const waypoints = nmeaPointsToWaypoints([
|
||||
{ timestamp: 1, lat: 54.0, lng: 10.0, sog: 6, cog: 90 },
|
||||
{ timestamp: 2, windDir: 180 },
|
||||
{ timestamp: 3, lat: 54.01, lng: 10.01, hdt: 95 }
|
||||
])
|
||||
|
||||
expect(waypoints).toHaveLength(2)
|
||||
expect(waypoints[0]).toMatchObject({ lat: 54, lng: 10, speedKnots: 6, heading: 90 })
|
||||
expect(waypoints[1].heading).toBe(95)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,283 @@
|
||||
import type { NmeaParseResult, NmeaParseStats, NmeaTimePoint } from './nmeaTypes.js'
|
||||
|
||||
function parseChecksum(line: string): boolean {
|
||||
const star = line.lastIndexOf('*')
|
||||
if (star < 0) return true
|
||||
const expected = line.slice(star + 1, star + 3)
|
||||
if (!/^[0-9A-Fa-f]{2}$/.test(expected)) return false
|
||||
let sum = 0
|
||||
for (let i = 1; i < star; i++) sum ^= line.charCodeAt(i)
|
||||
return sum.toString(16).toUpperCase().padStart(2, '0') === expected.toUpperCase()
|
||||
}
|
||||
|
||||
function sentenceType(field0: string): string {
|
||||
return field0.length >= 3 ? field0.slice(-3) : field0
|
||||
}
|
||||
|
||||
function parseLatLon(latStr: string, latHem: string, lonStr: string, lonHem: string): { lat?: number; lng?: number } {
|
||||
const latVal = parseFloat(latStr)
|
||||
const lonVal = parseFloat(lonStr)
|
||||
if (Number.isNaN(latVal) || Number.isNaN(lonVal)) return {}
|
||||
const latDeg = Math.floor(latVal / 100)
|
||||
const latMin = latVal - latDeg * 100
|
||||
let lat = latDeg + latMin / 60
|
||||
if (latHem === 'S') lat = -lat
|
||||
|
||||
const lonDeg = Math.floor(lonVal / 100)
|
||||
const lonMin = lonVal - lonDeg * 100
|
||||
let lng = lonDeg + lonMin / 60
|
||||
if (lonHem === 'W') lng = -lng
|
||||
|
||||
return { lat: Number(lat.toFixed(6)), lng: Number(lng.toFixed(6)) }
|
||||
}
|
||||
|
||||
function parseRmcDateTime(timeStr: string, dateStr: string, baseYear = new Date().getFullYear()): number | null {
|
||||
if (!timeStr || timeStr.length < 6) return null
|
||||
const hh = parseInt(timeStr.slice(0, 2), 10)
|
||||
const mm = parseInt(timeStr.slice(2, 4), 10)
|
||||
const ss = parseInt(timeStr.slice(4, 6), 10)
|
||||
if ([hh, mm, ss].some((n) => Number.isNaN(n))) return null
|
||||
|
||||
let year = baseYear
|
||||
let month = 0
|
||||
let day = 1
|
||||
if (dateStr && dateStr.length >= 6) {
|
||||
day = parseInt(dateStr.slice(0, 2), 10)
|
||||
month = parseInt(dateStr.slice(2, 4), 10) - 1
|
||||
const yy = parseInt(dateStr.slice(4, 6), 10)
|
||||
year = yy >= 70 ? 1900 + yy : 2000 + yy
|
||||
}
|
||||
|
||||
return Date.UTC(year, month, day, hh, mm, ss)
|
||||
}
|
||||
|
||||
function parseWindSpeed(value: string, unit: string): number | undefined {
|
||||
const speed = parseFloat(value)
|
||||
if (Number.isNaN(speed)) return undefined
|
||||
if (unit === 'N') return speed
|
||||
if (unit === 'M') return speed * 1.94384
|
||||
if (unit === 'K') return speed * 0.539957
|
||||
return speed
|
||||
}
|
||||
|
||||
interface MutableState extends NmeaTimePoint {
|
||||
lastTimestamp: number | null
|
||||
}
|
||||
|
||||
function snapshot(state: MutableState): NmeaTimePoint | null {
|
||||
if (state.lastTimestamp == null) return null
|
||||
const { lastTimestamp, ...rest } = state
|
||||
void lastTimestamp
|
||||
if (
|
||||
rest.lat == null &&
|
||||
rest.lng == null &&
|
||||
rest.cog == null &&
|
||||
rest.sog == null &&
|
||||
rest.hdt == null &&
|
||||
rest.windDir == null &&
|
||||
rest.windSpeedKnots == null &&
|
||||
rest.depthM == null &&
|
||||
rest.rpm == null
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return rest as NmeaTimePoint
|
||||
}
|
||||
|
||||
function pushPoint(points: NmeaTimePoint[], state: MutableState) {
|
||||
const snap = snapshot(state)
|
||||
if (!snap) return
|
||||
const last = points[points.length - 1]
|
||||
if (last && last.timestamp === snap.timestamp) {
|
||||
points[points.length - 1] = { ...last, ...snap }
|
||||
return
|
||||
}
|
||||
points.push(snap)
|
||||
}
|
||||
|
||||
function applySentence(state: MutableState, type: string, fields: string[], points: NmeaTimePoint[]) {
|
||||
switch (type) {
|
||||
case 'RMC': {
|
||||
const status = fields[2]
|
||||
const ts = parseRmcDateTime(fields[1], fields[9])
|
||||
if (ts != null) {
|
||||
state.timestamp = ts
|
||||
state.lastTimestamp = ts
|
||||
}
|
||||
if (status === 'A') {
|
||||
Object.assign(state, parseLatLon(fields[3], fields[4], fields[5], fields[6]))
|
||||
state.fixValid = true
|
||||
const sog = parseFloat(fields[7])
|
||||
const cog = parseFloat(fields[8])
|
||||
if (!Number.isNaN(sog)) state.sog = sog
|
||||
if (!Number.isNaN(cog)) state.cog = cog
|
||||
} else {
|
||||
state.fixValid = false
|
||||
}
|
||||
pushPoint(points, state)
|
||||
break
|
||||
}
|
||||
case 'GGA': {
|
||||
const ts = parseRmcDateTime(fields[1], '')
|
||||
if (ts != null) {
|
||||
state.timestamp = ts
|
||||
state.lastTimestamp = ts
|
||||
}
|
||||
Object.assign(state, parseLatLon(fields[2], fields[3], fields[4], fields[5]))
|
||||
const quality = parseInt(fields[6], 10)
|
||||
state.fixValid = !Number.isNaN(quality) && quality > 0
|
||||
pushPoint(points, state)
|
||||
break
|
||||
}
|
||||
case 'GLL': {
|
||||
const ts = parseRmcDateTime(fields[5], fields[6] ?? '')
|
||||
if (ts != null) {
|
||||
state.timestamp = ts
|
||||
state.lastTimestamp = ts
|
||||
}
|
||||
Object.assign(state, parseLatLon(fields[1], fields[2], fields[3], fields[4]))
|
||||
state.fixValid = fields[7] === 'A'
|
||||
pushPoint(points, state)
|
||||
break
|
||||
}
|
||||
case 'VTG': {
|
||||
const cog = parseFloat(fields[1])
|
||||
const sog = parseFloat(fields[5] || fields[7])
|
||||
if (!Number.isNaN(cog)) state.cog = cog
|
||||
if (!Number.isNaN(sog)) state.sog = sog
|
||||
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||
break
|
||||
}
|
||||
case 'HDT':
|
||||
state.hdt = parseFloat(fields[1])
|
||||
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||
break
|
||||
case 'HDM':
|
||||
state.hdm = parseFloat(fields[1])
|
||||
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||
break
|
||||
case 'HDG': {
|
||||
const hdg = parseFloat(fields[1])
|
||||
if (!Number.isNaN(hdg)) state.hdm = hdg
|
||||
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||
break
|
||||
}
|
||||
case 'MWV': {
|
||||
if (fields[5] !== 'A') break
|
||||
const dir = parseFloat(fields[1])
|
||||
const speed = parseWindSpeed(fields[3], fields[4])
|
||||
if (!Number.isNaN(dir)) state.windDir = dir
|
||||
if (speed != null) state.windSpeedKnots = Number(speed.toFixed(1))
|
||||
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||
break
|
||||
}
|
||||
case 'MWD': {
|
||||
const dir = parseFloat(fields[1])
|
||||
const speed = parseFloat(fields[5])
|
||||
if (!Number.isNaN(dir)) state.windDir = dir
|
||||
if (!Number.isNaN(speed)) state.windSpeedKnots = Number(speed.toFixed(1))
|
||||
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||
break
|
||||
}
|
||||
case 'DPT':
|
||||
case 'DBT': {
|
||||
const depth = parseFloat(fields[1])
|
||||
if (!Number.isNaN(depth)) state.depthM = depth
|
||||
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||
break
|
||||
}
|
||||
case 'RPM': {
|
||||
const rpm = parseFloat(fields[3] ?? fields[2])
|
||||
if (!Number.isNaN(rpm)) state.rpm = rpm
|
||||
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||
break
|
||||
}
|
||||
case 'MDA': {
|
||||
const inchHg = parseFloat(fields[3])
|
||||
const hpaField = parseFloat(fields[15] ?? fields[4])
|
||||
if (!Number.isNaN(hpaField) && hpaField > 800) state.pressureHpa = hpaField
|
||||
else if (!Number.isNaN(inchHg)) state.pressureHpa = inchHg * 33.8639
|
||||
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||
break
|
||||
}
|
||||
case 'MTW': {
|
||||
const temp = parseFloat(fields[1])
|
||||
if (!Number.isNaN(temp)) state.waterTempC = temp
|
||||
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||
break
|
||||
}
|
||||
case 'VLW': {
|
||||
const nm = parseFloat(fields[1] ?? fields[2])
|
||||
if (!Number.isNaN(nm)) state.logDistanceNm = nm
|
||||
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||
break
|
||||
}
|
||||
case 'APA': {
|
||||
const mode = fields[1]
|
||||
state.autopilotEngaged = mode === '1' || mode?.toUpperCase() === 'A'
|
||||
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
export function parseNmeaFile(text: string, filename: string): NmeaParseResult {
|
||||
const warnings: string[] = []
|
||||
const points: NmeaTimePoint[] = []
|
||||
const typesSeen = new Set<string>()
|
||||
let totalLines = 0
|
||||
let parsedLines = 0
|
||||
let checksumErrors = 0
|
||||
|
||||
const state: MutableState = { timestamp: 0, lastTimestamp: null }
|
||||
|
||||
for (const rawLine of text.split(/\r?\n/)) {
|
||||
const line = rawLine.trim()
|
||||
if (!line || (!line.startsWith('$') && !line.startsWith('!'))) continue
|
||||
totalLines++
|
||||
if (!parseChecksum(line)) {
|
||||
checksumErrors++
|
||||
continue
|
||||
}
|
||||
|
||||
const star = line.indexOf('*')
|
||||
const body = star >= 0 ? line.slice(0, star) : line
|
||||
const fields = body.slice(1).split(',')
|
||||
if (fields.length < 2) continue
|
||||
|
||||
const type = sentenceType(fields[0])
|
||||
typesSeen.add(type)
|
||||
applySentence(state, type, fields, points)
|
||||
parsedLines++
|
||||
}
|
||||
|
||||
if (points.length === 0) {
|
||||
warnings.push('no_samples')
|
||||
}
|
||||
if (!typesSeen.has('RMC') && !typesSeen.has('GGA') && !typesSeen.has('GLL')) {
|
||||
warnings.push('no_position')
|
||||
}
|
||||
|
||||
const stats: NmeaParseStats = {
|
||||
totalLines,
|
||||
parsedLines,
|
||||
checksumErrors,
|
||||
sentenceTypes: [...typesSeen].sort()
|
||||
}
|
||||
|
||||
return { points, stats, warnings, rawText: text, filename }
|
||||
}
|
||||
|
||||
export function nmeaPointsToWaypoints(points: NmeaTimePoint[]): import('../trackUpload.js').TrackWaypoint[] {
|
||||
return points
|
||||
.filter((p) => p.lat != null && p.lng != null)
|
||||
.map((p) => ({
|
||||
timestamp: p.timestamp,
|
||||
lat: p.lat!,
|
||||
lng: p.lng!,
|
||||
speedKnots: p.sog,
|
||||
heading: p.cog ?? p.hdt ?? p.hdm
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { NmeaTimePoint } from './nmeaTypes.js'
|
||||
|
||||
/** Nearest sample at or before timestamp (carry-forward). */
|
||||
export function sampleAt(points: NmeaTimePoint[], timestamp: number): NmeaTimePoint | null {
|
||||
if (points.length === 0) return null
|
||||
let best: NmeaTimePoint | null = null
|
||||
for (const p of points) {
|
||||
if (p.timestamp <= timestamp) best = p
|
||||
else break
|
||||
}
|
||||
return best ?? points[0]
|
||||
}
|
||||
|
||||
export function filterPointsForDate(points: NmeaTimePoint[], dateYmd: string): NmeaTimePoint[] {
|
||||
if (!dateYmd || points.length === 0) return points
|
||||
const [y, m, d] = dateYmd.split('-').map((v) => parseInt(v, 10))
|
||||
if ([y, m, d].some((n) => Number.isNaN(n))) return points
|
||||
|
||||
const start = Date.UTC(y, m - 1, d, 0, 0, 0)
|
||||
const end = Date.UTC(y, m - 1, d, 23, 59, 59)
|
||||
|
||||
const filtered = points.filter((p) => p.timestamp >= start && p.timestamp <= end)
|
||||
return filtered.length > 0 ? filtered : points
|
||||
}
|
||||
|
||||
export function timestampToHHMM(timestamp: number, timeZone?: string): string {
|
||||
const opts: Intl.DateTimeFormatOptions = {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
timeZone: timeZone ?? undefined
|
||||
}
|
||||
const parts = new Intl.DateTimeFormat('en-GB', opts).formatToParts(new Date(timestamp))
|
||||
const hh = parts.find((p) => p.type === 'hour')?.value ?? '00'
|
||||
const mm = parts.find((p) => p.type === 'minute')?.value ?? '00'
|
||||
return `${hh}:${mm}`
|
||||
}
|
||||
|
||||
export function angularDelta(a: number, b: number): number {
|
||||
const diff = Math.abs(a - b) % 360
|
||||
return diff > 180 ? 360 - diff : diff
|
||||
}
|
||||
|
||||
export function intervalTimestamps(
|
||||
points: NmeaTimePoint[],
|
||||
intervalMinutes: number
|
||||
): number[] {
|
||||
if (points.length === 0) return []
|
||||
const start = points[0].timestamp
|
||||
const end = points[points.length - 1].timestamp
|
||||
const stepMs = intervalMinutes * 60 * 1000
|
||||
const stamps: number[] = []
|
||||
for (let t = start; t <= end; t += stepMs) {
|
||||
stamps.push(t)
|
||||
}
|
||||
if (stamps[stamps.length - 1] !== end) stamps.push(end)
|
||||
return stamps
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
export type NmeaChangeType =
|
||||
| 'course'
|
||||
| 'wind'
|
||||
| 'pressure'
|
||||
| 'engine_start'
|
||||
| 'engine_stop'
|
||||
| 'autopilot_on'
|
||||
| 'autopilot_off'
|
||||
| 'depth'
|
||||
| 'anchor'
|
||||
| 'departure'
|
||||
| 'speed'
|
||||
| 'gps_fix_lost'
|
||||
| 'gps_fix_regained'
|
||||
| 'water_temp'
|
||||
| 'wind_shift'
|
||||
|
||||
export interface NmeaParseStats {
|
||||
totalLines: number
|
||||
parsedLines: number
|
||||
checksumErrors: number
|
||||
sentenceTypes: string[]
|
||||
}
|
||||
|
||||
export interface NmeaTimePoint {
|
||||
timestamp: number
|
||||
lat?: number
|
||||
lng?: number
|
||||
cog?: number
|
||||
sog?: number
|
||||
hdt?: number
|
||||
hdm?: number
|
||||
windDir?: number
|
||||
windSpeedKnots?: number
|
||||
depthM?: number
|
||||
rpm?: number
|
||||
pressureHpa?: number
|
||||
waterTempC?: number
|
||||
logDistanceNm?: number
|
||||
fixValid?: boolean
|
||||
autopilotEngaged?: boolean
|
||||
}
|
||||
|
||||
export interface NmeaChangeEvent {
|
||||
type: NmeaChangeType
|
||||
timestamp: number
|
||||
confidence: 'high' | 'medium' | 'low'
|
||||
summaryKey: string
|
||||
summaryParams?: Record<string, string | number>
|
||||
data?: Partial<NmeaTimePoint>
|
||||
}
|
||||
|
||||
export interface NmeaParseResult {
|
||||
points: NmeaTimePoint[]
|
||||
stats: NmeaParseStats
|
||||
warnings: string[]
|
||||
rawText: string
|
||||
filename: string
|
||||
}
|
||||
|
||||
export type NmeaImportMode = 'interval' | 'change' | 'both'
|
||||
|
||||
export interface NmeaJournalCandidate {
|
||||
id: string
|
||||
timestamp: number
|
||||
source: 'interval' | 'change'
|
||||
changeType?: NmeaChangeType
|
||||
confidence?: 'high' | 'medium' | 'low'
|
||||
selected: boolean
|
||||
}
|
||||
|
||||
export interface NmeaDetectionConfig {
|
||||
courseDeltaDeg: number
|
||||
windDirDeltaDeg: number
|
||||
windSpeedDeltaKnots: number
|
||||
pressureDeltaHpa: number
|
||||
depthDeltaM: number
|
||||
depthDeltaPercent: number
|
||||
rpmIdle: number
|
||||
rpmRunning: number
|
||||
sogUnderWayKn: number
|
||||
sogStoppedKn: number
|
||||
anchorMinutes: number
|
||||
speedDeltaKn: number
|
||||
dedupeWindowMs: number
|
||||
}
|
||||
|
||||
export const DEFAULT_NMEA_DETECTION_CONFIG: NmeaDetectionConfig = {
|
||||
courseDeltaDeg: 28,
|
||||
windDirDeltaDeg: 35,
|
||||
windSpeedDeltaKnots: 4,
|
||||
pressureDeltaHpa: 2,
|
||||
depthDeltaM: 2,
|
||||
depthDeltaPercent: 25,
|
||||
rpmIdle: 400,
|
||||
rpmRunning: 800,
|
||||
sogUnderWayKn: 2,
|
||||
sogStoppedKn: 0.5,
|
||||
anchorMinutes: 10,
|
||||
speedDeltaKn: 3,
|
||||
dedupeWindowMs: 5 * 60 * 1000
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isNmeaCrcAlreadyImported, type NmeaArchiveRecord } from './nmeaArchive.js'
|
||||
import { nmeaFileCrc32 } from '../utils/crc32.js'
|
||||
|
||||
describe('nmeaArchive CRC tracking', () => {
|
||||
it('detects duplicate file content by CRC32', () => {
|
||||
const text = '$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W\n'
|
||||
const record: NmeaArchiveRecord = {
|
||||
filename: 'a.nmea',
|
||||
rawText: '',
|
||||
importedAt: '2026-05-29T10:00:00.000Z',
|
||||
importedFiles: [{
|
||||
crc32: nmeaFileCrc32(text),
|
||||
filename: 'a.nmea',
|
||||
importedAt: '2026-05-29T10:00:00.000Z'
|
||||
}]
|
||||
}
|
||||
|
||||
expect(isNmeaCrcAlreadyImported(record, text)).toBe(true)
|
||||
expect(isNmeaCrcAlreadyImported(record, text.replace('\n', '\r\n'))).toBe(true)
|
||||
expect(isNmeaCrcAlreadyImported(record, '$GPRMC,999999,A\n')).toBe(false)
|
||||
expect(isNmeaCrcAlreadyImported(null, text)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,146 @@
|
||||
import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { getLogbookKey } from './logbookKeys.js'
|
||||
import { encryptJson, decryptJson } from './crypto.js'
|
||||
import { nmeaFileCrc32 } from '../utils/crc32.js'
|
||||
|
||||
export interface NmeaImportedFile {
|
||||
crc32: string
|
||||
filename: string
|
||||
importedAt: string
|
||||
}
|
||||
|
||||
export interface NmeaArchiveRecord {
|
||||
filename: string
|
||||
rawText: string
|
||||
importedAt: string
|
||||
importedFiles: NmeaImportedFile[]
|
||||
}
|
||||
|
||||
function normalizeArchiveRecord(raw: Partial<NmeaArchiveRecord>): NmeaArchiveRecord {
|
||||
const importedFiles = [...(raw.importedFiles ?? [])]
|
||||
if (importedFiles.length === 0 && raw.rawText) {
|
||||
importedFiles.push({
|
||||
crc32: nmeaFileCrc32(raw.rawText),
|
||||
filename: raw.filename ?? '',
|
||||
importedAt: raw.importedAt ?? ''
|
||||
})
|
||||
}
|
||||
return {
|
||||
filename: raw.filename ?? '',
|
||||
rawText: raw.rawText ?? '',
|
||||
importedAt: raw.importedAt ?? '',
|
||||
importedFiles
|
||||
}
|
||||
}
|
||||
|
||||
async function putNmeaArchiveRecord(
|
||||
logbookId: string,
|
||||
entryId: string,
|
||||
payload: NmeaArchiveRecord
|
||||
): Promise<void> {
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
|
||||
const encrypted = await encryptJson(payload, masterKey)
|
||||
await db.nmeaArchives.put({
|
||||
entryId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: payload.importedAt || new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
export async function getNmeaArchive(entryId: string): Promise<NmeaArchiveRecord | null> {
|
||||
const record = await db.nmeaArchives.get(entryId)
|
||||
if (!record) return null
|
||||
|
||||
const masterKey = await getLogbookKey(record.logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
|
||||
try {
|
||||
return normalizeArchiveRecord(
|
||||
await decryptJson(record.encryptedData, record.iv, record.tag, masterKey) as Partial<NmeaArchiveRecord>
|
||||
)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function isNmeaCrcAlreadyImported(record: NmeaArchiveRecord | null, rawText: string): boolean {
|
||||
if (!record) return false
|
||||
const crc32 = nmeaFileCrc32(rawText)
|
||||
return record.importedFiles.some((file) => file.crc32 === crc32)
|
||||
}
|
||||
|
||||
/** Remember imported file by CRC (even when raw log is discarded). */
|
||||
export async function recordNmeaFileImport(
|
||||
logbookId: string,
|
||||
entryId: string,
|
||||
filename: string,
|
||||
rawText: string
|
||||
): Promise<string> {
|
||||
const crc32 = nmeaFileCrc32(rawText)
|
||||
const existing = await getNmeaArchive(entryId)
|
||||
const importedFiles = [...(existing?.importedFiles ?? [])]
|
||||
if (!importedFiles.some((file) => file.crc32 === crc32)) {
|
||||
importedFiles.push({
|
||||
crc32,
|
||||
filename,
|
||||
importedAt: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
const payload: NmeaArchiveRecord = {
|
||||
filename: existing?.filename ?? '',
|
||||
rawText: existing?.rawText ?? '',
|
||||
importedAt: new Date().toISOString(),
|
||||
importedFiles
|
||||
}
|
||||
await putNmeaArchiveRecord(logbookId, entryId, payload)
|
||||
return crc32
|
||||
}
|
||||
|
||||
export async function saveNmeaArchive(
|
||||
logbookId: string,
|
||||
entryId: string,
|
||||
filename: string,
|
||||
rawText: string
|
||||
): Promise<void> {
|
||||
const crc32 = nmeaFileCrc32(rawText)
|
||||
const existing = await getNmeaArchive(entryId)
|
||||
const importedFiles = [...(existing?.importedFiles ?? [])]
|
||||
if (!importedFiles.some((file) => file.crc32 === crc32)) {
|
||||
importedFiles.push({
|
||||
crc32,
|
||||
filename,
|
||||
importedAt: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
const payload: NmeaArchiveRecord = {
|
||||
filename,
|
||||
rawText,
|
||||
importedAt: new Date().toISOString(),
|
||||
importedFiles
|
||||
}
|
||||
await putNmeaArchiveRecord(logbookId, entryId, payload)
|
||||
}
|
||||
|
||||
export async function deleteNmeaArchive(entryId: string): Promise<void> {
|
||||
await db.nmeaArchives.delete(entryId)
|
||||
}
|
||||
|
||||
export function downloadNmeaArchive(record: NmeaArchiveRecord): void {
|
||||
const blob = new Blob([record.rawText], { type: 'text/plain;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = record.filename || 'track.nmea'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
@@ -6,10 +6,10 @@ import { decryptJson } from './crypto.js'
|
||||
import { isSignatureImage, isPasskeySignature, isClassicSignature, getSignaturePayload } from '../utils/signatures.js'
|
||||
import { sortLogEventsByTime } from '../utils/logEntryPayload.js'
|
||||
import i18n from '../i18n/index.js'
|
||||
import { formatAppDateTime } from '../utils/dateTimeFormat.js'
|
||||
|
||||
function formatPasskeySignDate(signedAt: string): string {
|
||||
const locale = i18n.language === 'de' ? 'de-DE' : 'en-GB'
|
||||
return new Date(signedAt).toLocaleString(locale)
|
||||
return formatAppDateTime(signedAt, i18n.language)
|
||||
}
|
||||
|
||||
export async function generateLogbookPagePdf(logbookId: string, entryId: string, preloadedData?: { yacht: any; entry: any }): Promise<jsPDF> {
|
||||
@@ -197,13 +197,15 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
|
||||
doc.text('VERBRAUCHSWERTE / CONSUMPTON STATS', 10, footerY + 3);
|
||||
|
||||
let fwY = footerY + 5;
|
||||
doc.rect(10, fwY, 110, rowHeight * 3, 'S');
|
||||
const tankRows = 4;
|
||||
doc.rect(10, fwY, 110, rowHeight * tankRows, 'S');
|
||||
doc.line(10, fwY + rowHeight, 120, fwY + rowHeight);
|
||||
doc.line(10, fwY + rowHeight * 2, 120, fwY + rowHeight * 2);
|
||||
doc.line(40, fwY, 40, fwY + rowHeight * 3);
|
||||
doc.line(60, fwY, 60, fwY + rowHeight * 3);
|
||||
doc.line(80, fwY, 80, fwY + rowHeight * 3);
|
||||
doc.line(100, fwY, 100, fwY + rowHeight * 3);
|
||||
doc.line(10, fwY + rowHeight * 3, 120, fwY + rowHeight * 3);
|
||||
doc.line(40, fwY, 40, fwY + rowHeight * tankRows);
|
||||
doc.line(60, fwY, 60, fwY + rowHeight * tankRows);
|
||||
doc.line(80, fwY, 80, fwY + rowHeight * tankRows);
|
||||
doc.line(100, fwY, 100, fwY + rowHeight * tankRows);
|
||||
|
||||
doc.setFont('Helvetica', 'bold');
|
||||
doc.setFontSize(7.5);
|
||||
@@ -226,6 +228,12 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
|
||||
doc.text(String(entry.fuel?.evening ?? '0'), 81, fwY + rowHeight * 2 + 4.2);
|
||||
doc.text(String(entry.fuel?.consumption ?? '0'), 101, fwY + rowHeight * 2 + 4.2);
|
||||
|
||||
doc.text('Grauwasser', 11, fwY + rowHeight * 3 + 4.2);
|
||||
doc.text('—', 41, fwY + rowHeight * 3 + 4.2);
|
||||
doc.text('—', 61, fwY + rowHeight * 3 + 4.2);
|
||||
doc.text(String(entry.greywater?.level ?? '0'), 81, fwY + rowHeight * 3 + 4.2);
|
||||
doc.text('—', 101, fwY + rowHeight * 3 + 4.2);
|
||||
|
||||
// Signatures Box
|
||||
let sigX = 130;
|
||||
let sigY = footerY + 5;
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { getLogbookKey } from './logbookKeys.js'
|
||||
import { encryptJson } from './crypto.js'
|
||||
import { syncLogbook } from './sync.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
|
||||
|
||||
async function getEncryptionKey(logbookId: string): Promise<ArrayBuffer> {
|
||||
const key = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!key) throw new Error('Encryption key not found. Please log in.')
|
||||
return key
|
||||
}
|
||||
|
||||
export async function saveEntryPhoto(options: {
|
||||
logbookId: string
|
||||
entryId: string
|
||||
imageDataUrl: string
|
||||
caption?: string
|
||||
analyticsContext?: string
|
||||
}): Promise<string> {
|
||||
const { logbookId, entryId, imageDataUrl, caption = '', analyticsContext = 'logbook' } = options
|
||||
const masterKey = await getEncryptionKey(logbookId)
|
||||
const photoId = window.crypto.randomUUID()
|
||||
const photoPayload = {
|
||||
image: imageDataUrl,
|
||||
caption: caption.trim()
|
||||
}
|
||||
|
||||
const encrypted = await encryptJson(photoPayload, masterKey)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
await db.photos.put({
|
||||
payloadId: photoId,
|
||||
entryId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
caption: '',
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
await db.syncQueue.put({
|
||||
action: 'create',
|
||||
type: 'photo',
|
||||
payloadId: photoId,
|
||||
logbookId,
|
||||
data: JSON.stringify({
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
entryId
|
||||
}),
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: analyticsContext })
|
||||
if (analyticsContext === 'live_log') {
|
||||
trackPlausibleEvent(PlausibleEvents.LIVE_LOG_PHOTO_UPLOADED)
|
||||
}
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
return photoId
|
||||
}
|
||||
|
||||
export async function deleteEntryPhoto(logbookId: string, photoId: string): Promise<void> {
|
||||
const now = new Date().toISOString()
|
||||
await db.photos.delete(photoId)
|
||||
await db.syncQueue.put({
|
||||
action: 'delete',
|
||||
type: 'photo',
|
||||
payloadId: photoId,
|
||||
logbookId,
|
||||
data: '',
|
||||
updatedAt: now
|
||||
})
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
}
|
||||
|
||||
/** Deletes the newest photo for an entry; returns its id or null. */
|
||||
export async function removeLastPhotoForEntry(
|
||||
logbookId: string,
|
||||
entryId: string
|
||||
): Promise<string | null> {
|
||||
const photos = await db.photos.where({ entryId }).toArray()
|
||||
if (photos.length === 0) return null
|
||||
photos.sort(
|
||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||
)
|
||||
const lastId = photos[0].payloadId
|
||||
await deleteEntryPhoto(logbookId, lastId)
|
||||
return lastId
|
||||
}
|
||||
@@ -43,6 +43,18 @@ async function fetchVapidPublicKey(): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
/** True when crew-change push is enabled and notification permission is granted. */
|
||||
export async function isCollaboratorPushActive(): Promise<boolean> {
|
||||
if (!isPushSupported()) return false
|
||||
if (getNotificationPermission() !== 'granted') return false
|
||||
try {
|
||||
const prefs = await fetchPushPrefs()
|
||||
return prefs.collaboratorChangesEnabled
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchPushPrefs(): Promise<{ collaboratorChangesEnabled: boolean }> {
|
||||
if (!localStorage.getItem('active_userid')) {
|
||||
return { collaboratorChangesEnabled: false }
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
forcePwaRecovery,
|
||||
markReloadAttempt,
|
||||
recentlyAttemptedReload,
|
||||
reconcileServiceWorkerOnStartup,
|
||||
reconcileVersionOnStartup
|
||||
} from './pwaStartup.js'
|
||||
|
||||
const STALE_RECOVERY_COUNT_KEY = 'pwa_stale_recovery_count'
|
||||
const STALE_RECOVERY_LAST_KEY = 'pwa_stale_recovery_last_ts'
|
||||
|
||||
describe('pwaStartup reload guards', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
it('blocks repeated reload attempts within the debounce window', () => {
|
||||
expect(recentlyAttemptedReload(10_000)).toBe(false)
|
||||
markReloadAttempt(10_000)
|
||||
expect(recentlyAttemptedReload(12_000)).toBe(true)
|
||||
expect(recentlyAttemptedReload(15_000)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('forcePwaRecovery stale counter reset', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
vi.unstubAllEnvs()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('clears stale recovery counter before hard recovery reload', async () => {
|
||||
vi.stubEnv('DEV', false)
|
||||
sessionStorage.setItem(STALE_RECOVERY_COUNT_KEY, '2')
|
||||
sessionStorage.setItem(STALE_RECOVERY_LAST_KEY, String(Date.now()))
|
||||
|
||||
const reload = vi.fn()
|
||||
vi.stubGlobal('location', { reload })
|
||||
vi.stubGlobal('caches', {
|
||||
keys: vi.fn().mockResolvedValue([]),
|
||||
delete: vi.fn()
|
||||
})
|
||||
Object.defineProperty(navigator, 'serviceWorker', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getRegistrations: vi.fn().mockResolvedValue([])
|
||||
}
|
||||
})
|
||||
|
||||
await forcePwaRecovery()
|
||||
|
||||
expect(sessionStorage.getItem(STALE_RECOVERY_COUNT_KEY)).toBeNull()
|
||||
expect(sessionStorage.getItem(STALE_RECOVERY_LAST_KEY)).toBeNull()
|
||||
expect(reload).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('returns false when hard recovery was just attempted', async () => {
|
||||
sessionStorage.setItem('pwa_hard_recovery_ts', String(Date.now()))
|
||||
const result = await forcePwaRecovery()
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reconcileServiceWorkerOnStartup', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('returns false in dev mode', async () => {
|
||||
vi.stubEnv('DEV', true)
|
||||
await expect(reconcileServiceWorkerOnStartup()).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when no waiting worker exists', async () => {
|
||||
vi.stubEnv('DEV', false)
|
||||
Object.defineProperty(navigator, 'serviceWorker', {
|
||||
configurable: true,
|
||||
value: {
|
||||
controller: {},
|
||||
getRegistration: vi.fn().mockResolvedValue({
|
||||
waiting: null,
|
||||
installing: null,
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
addEventListener: vi.fn()
|
||||
}),
|
||||
addEventListener: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
await expect(reconcileServiceWorkerOnStartup()).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when waiting worker activation never takes over', async () => {
|
||||
vi.useFakeTimers()
|
||||
const postMessage = vi.fn()
|
||||
const addEventListener = vi.fn()
|
||||
vi.stubEnv('DEV', false)
|
||||
|
||||
Object.defineProperty(navigator, 'serviceWorker', {
|
||||
configurable: true,
|
||||
value: {
|
||||
controller: { scriptURL: '/sw.js?v=1' },
|
||||
getRegistration: vi.fn().mockResolvedValue({
|
||||
waiting: { postMessage },
|
||||
installing: null,
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
addEventListener: vi.fn()
|
||||
}),
|
||||
addEventListener
|
||||
}
|
||||
})
|
||||
|
||||
const reconcilePromise = reconcileServiceWorkerOnStartup()
|
||||
await vi.advanceTimersByTimeAsync(4_000)
|
||||
|
||||
await expect(reconcilePromise).resolves.toBe(false)
|
||||
expect(postMessage).toHaveBeenCalledWith({ type: 'SKIP_WAITING' })
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
describe('reconcileVersionOnStartup', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
vi.unstubAllEnvs()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns noop in dev mode', async () => {
|
||||
vi.stubEnv('DEV', true)
|
||||
await expect(reconcileVersionOnStartup()).resolves.toBe('noop')
|
||||
})
|
||||
|
||||
it('returns noop when deployed version matches bundled version', async () => {
|
||||
vi.stubEnv('DEV', false)
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ version: '0.1.0.57' })
|
||||
}))
|
||||
vi.stubGlobal('__APP_VERSION__', '0.1.0.57')
|
||||
|
||||
await expect(reconcileVersionOnStartup()).resolves.toBe('noop')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,289 @@
|
||||
import { isNewerAppVersion, fetchDeployedVersion, getAppVersion } from './pwaVersion.js'
|
||||
|
||||
const RELOAD_ATTEMPT_KEY = 'pwa_reload_attempt_ts'
|
||||
const COLD_START_UPDATE_KEY = 'pwa_coldstart_update_ts'
|
||||
const HARD_RECOVERY_KEY = 'pwa_hard_recovery_ts'
|
||||
const STALE_RECOVERY_COUNT_KEY = 'pwa_stale_recovery_count'
|
||||
const STALE_RECOVERY_LAST_KEY = 'pwa_stale_recovery_last_ts'
|
||||
const STALE_RECOVERY_WINDOW_MS = 60_000
|
||||
const RELOAD_DEBOUNCE_MS = 4_000
|
||||
const COLD_START_UPDATE_DEBOUNCE_MS = 15_000
|
||||
const HARD_RECOVERY_DEBOUNCE_MS = 30_000
|
||||
|
||||
export function recentlyAttemptedReload(now = Date.now()): boolean {
|
||||
const last = Number(sessionStorage.getItem(RELOAD_ATTEMPT_KEY) || '0')
|
||||
return now - last < RELOAD_DEBOUNCE_MS
|
||||
}
|
||||
|
||||
export function markReloadAttempt(now = Date.now()): void {
|
||||
sessionStorage.setItem(RELOAD_ATTEMPT_KEY, String(now))
|
||||
}
|
||||
|
||||
function recentlyAttemptedColdStartUpdate(now = Date.now()): boolean {
|
||||
const last = Number(sessionStorage.getItem(COLD_START_UPDATE_KEY) || '0')
|
||||
return now - last < COLD_START_UPDATE_DEBOUNCE_MS
|
||||
}
|
||||
|
||||
function markColdStartUpdateAttempt(now = Date.now()): void {
|
||||
sessionStorage.setItem(COLD_START_UPDATE_KEY, String(now))
|
||||
}
|
||||
|
||||
function recentlyAttemptedHardRecovery(now = Date.now()): boolean {
|
||||
const last = Number(sessionStorage.getItem(HARD_RECOVERY_KEY) || '0')
|
||||
return now - last < HARD_RECOVERY_DEBOUNCE_MS
|
||||
}
|
||||
|
||||
function markHardRecoveryAttempt(now = Date.now()): void {
|
||||
sessionStorage.setItem(HARD_RECOVERY_KEY, String(now))
|
||||
}
|
||||
|
||||
function resetStaleRecoveryCount(): void {
|
||||
sessionStorage.removeItem(STALE_RECOVERY_COUNT_KEY)
|
||||
sessionStorage.removeItem(STALE_RECOVERY_LAST_KEY)
|
||||
}
|
||||
|
||||
function incrementStaleRecoveryCount(now = Date.now()): number {
|
||||
const last = Number(sessionStorage.getItem(STALE_RECOVERY_LAST_KEY) || '0')
|
||||
let current = Number(sessionStorage.getItem(STALE_RECOVERY_COUNT_KEY) || '0')
|
||||
|
||||
if (now - last > STALE_RECOVERY_WINDOW_MS) {
|
||||
current = 0
|
||||
}
|
||||
|
||||
const next = current + 1
|
||||
sessionStorage.setItem(STALE_RECOVERY_COUNT_KEY, String(next))
|
||||
sessionStorage.setItem(STALE_RECOVERY_LAST_KEY, String(now))
|
||||
return next
|
||||
}
|
||||
|
||||
function isStaleModuleLoadError(error: unknown): boolean {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: typeof error === 'string'
|
||||
? error
|
||||
: ''
|
||||
|
||||
return (
|
||||
message.includes('Failed to fetch dynamically imported module') ||
|
||||
message.includes('Importing a module script failed') ||
|
||||
message.includes('error loading dynamically imported module') ||
|
||||
message.includes('Loading chunk') ||
|
||||
message.includes('ChunkLoadError') ||
|
||||
message.includes('Unable to preload CSS')
|
||||
)
|
||||
}
|
||||
|
||||
export async function clearPwaCachesAndWorkers(): Promise<void> {
|
||||
if ('serviceWorker' in navigator) {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations()
|
||||
await Promise.all(registrations.map((registration) => registration.unregister()))
|
||||
}
|
||||
|
||||
if ('caches' in window) {
|
||||
const keys = await caches.keys()
|
||||
await Promise.all(keys.map((key) => caches.delete(key)))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Last-resort recovery when soft reloads cannot escape a stale precache.
|
||||
* Equivalent to manually clearing site data / reinstalling the PWA.
|
||||
*/
|
||||
export async function forcePwaRecovery(): Promise<boolean> {
|
||||
if (recentlyAttemptedHardRecovery()) return false
|
||||
|
||||
markHardRecoveryAttempt()
|
||||
markReloadAttempt()
|
||||
resetStaleRecoveryCount()
|
||||
await clearPwaCachesAndWorkers()
|
||||
window.location.reload()
|
||||
return true
|
||||
}
|
||||
|
||||
async function waitForWaitingWorker(
|
||||
registration: ServiceWorkerRegistration,
|
||||
timeoutMs: number
|
||||
): Promise<ServiceWorker | null> {
|
||||
if (registration.waiting) {
|
||||
return registration.waiting
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const timeoutId = window.setTimeout(() => resolve(null), timeoutMs)
|
||||
|
||||
const inspectWorker = (worker: ServiceWorker | null) => {
|
||||
if (!worker) return
|
||||
|
||||
if (worker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||
window.clearTimeout(timeoutId)
|
||||
resolve(worker)
|
||||
return
|
||||
}
|
||||
|
||||
worker.addEventListener(
|
||||
'statechange',
|
||||
() => {
|
||||
if (worker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||
window.clearTimeout(timeoutId)
|
||||
resolve(worker)
|
||||
}
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
}
|
||||
|
||||
inspectWorker(registration.installing)
|
||||
|
||||
registration.addEventListener(
|
||||
'updatefound',
|
||||
() => {
|
||||
inspectWorker(registration.installing)
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export async function triggerServiceWorkerUpdate(timeoutMs = 5_000): Promise<boolean> {
|
||||
if (import.meta.env.DEV || !('serviceWorker' in navigator)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const registration = await navigator.serviceWorker.getRegistration()
|
||||
if (!registration) return false
|
||||
|
||||
try {
|
||||
await registration.update()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
const waiting = await waitForWaitingWorker(registration, timeoutMs)
|
||||
return waiting !== null
|
||||
}
|
||||
|
||||
async function activateWaitingWorker(waiting: ServiceWorker): Promise<boolean> {
|
||||
const currentController = navigator.serviceWorker.controller?.scriptURL ?? null
|
||||
waiting.postMessage({ type: 'SKIP_WAITING' })
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const timeoutId = window.setTimeout(() => resolve(false), 4_000)
|
||||
navigator.serviceWorker.addEventListener(
|
||||
'controllerchange',
|
||||
() => {
|
||||
window.clearTimeout(timeoutId)
|
||||
const nextController = navigator.serviceWorker.controller?.scriptURL ?? null
|
||||
resolve(nextController !== null && nextController !== currentController)
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* After missed deploys, a waiting SW may exist while the page still runs an old bundle.
|
||||
* Apply the waiting worker once on cold start (one controlled reload) instead of hanging.
|
||||
*/
|
||||
export async function reconcileServiceWorkerOnStartup(): Promise<boolean> {
|
||||
if (import.meta.env.DEV || !('serviceWorker' in navigator)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (recentlyAttemptedColdStartUpdate()) {
|
||||
return false
|
||||
}
|
||||
|
||||
const registration = await navigator.serviceWorker.getRegistration()
|
||||
let waiting = registration?.waiting ?? null
|
||||
|
||||
if (!waiting && registration) {
|
||||
await registration.update().catch(() => {})
|
||||
waiting = await waitForWaitingWorker(registration, 4_000)
|
||||
}
|
||||
|
||||
if (!waiting || !navigator.serviceWorker.controller) {
|
||||
return false
|
||||
}
|
||||
|
||||
const activated = await activateWaitingWorker(waiting)
|
||||
if (activated) {
|
||||
markColdStartUpdateAttempt()
|
||||
}
|
||||
return activated
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare deployed version.json with the bundled app version.
|
||||
* When the server is ahead, try a soft SW takeover before hard recovery.
|
||||
*/
|
||||
export async function reconcileVersionOnStartup(): Promise<'reload' | 'recovered' | 'noop'> {
|
||||
if (import.meta.env.DEV || !navigator.onLine) {
|
||||
return 'noop'
|
||||
}
|
||||
|
||||
const deployedVersion = await fetchDeployedVersion()
|
||||
if (!deployedVersion || !isNewerAppVersion(deployedVersion, getAppVersion())) {
|
||||
return 'noop'
|
||||
}
|
||||
|
||||
const reconciled = await reconcileServiceWorkerOnStartup()
|
||||
if (reconciled) {
|
||||
return 'reload'
|
||||
}
|
||||
|
||||
const updated = await triggerServiceWorkerUpdate()
|
||||
if (updated) {
|
||||
const registration = await navigator.serviceWorker.getRegistration()
|
||||
const waiting = registration?.waiting
|
||||
if (waiting) {
|
||||
const activated = await activateWaitingWorker(waiting)
|
||||
if (activated) {
|
||||
markColdStartUpdateAttempt()
|
||||
return 'reload'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!recentlyAttemptedHardRecovery()) {
|
||||
const recovered = await forcePwaRecovery()
|
||||
if (recovered) {
|
||||
return 'recovered'
|
||||
}
|
||||
}
|
||||
|
||||
return 'noop'
|
||||
}
|
||||
|
||||
export function installStaleAssetRecovery(): void {
|
||||
if (import.meta.env.DEV) return
|
||||
|
||||
const recoverFromStaleAssets = () => {
|
||||
if (recentlyAttemptedReload()) return
|
||||
|
||||
const attempts = incrementStaleRecoveryCount()
|
||||
markReloadAttempt()
|
||||
|
||||
if (attempts >= 2) {
|
||||
void forcePwaRecovery()
|
||||
return
|
||||
}
|
||||
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
if (!isStaleModuleLoadError(event.reason)) return
|
||||
event.preventDefault()
|
||||
recoverFromStaleAssets()
|
||||
})
|
||||
|
||||
window.addEventListener(
|
||||
'error',
|
||||
(event) => {
|
||||
if (!isStaleModuleLoadError(event.message)) return
|
||||
recoverFromStaleAssets()
|
||||
},
|
||||
true
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
compareAppVersions,
|
||||
isNewerAppVersion,
|
||||
parseAppVersion
|
||||
} from './pwaVersion.js'
|
||||
|
||||
describe('pwaVersion', () => {
|
||||
it('parses semantic build versions', () => {
|
||||
expect(parseAppVersion('v0.1.0.57')).toEqual([0, 1, 0, 57])
|
||||
})
|
||||
|
||||
it('compares build numbers numerically', () => {
|
||||
expect(compareAppVersions('0.1.0.65', '0.1.0.57')).toBeGreaterThan(0)
|
||||
expect(compareAppVersions('0.1.0.57', '0.1.0.65')).toBeLessThan(0)
|
||||
expect(compareAppVersions('0.1.0.57', '0.1.0.57')).toBe(0)
|
||||
})
|
||||
|
||||
it('detects newer deployed versions', () => {
|
||||
expect(isNewerAppVersion('0.1.0.66', '0.1.0.57')).toBe(true)
|
||||
expect(isNewerAppVersion('0.1.0.57', '0.1.0.57')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
const APP_VERSION =
|
||||
typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0.0-dev'
|
||||
|
||||
export function getAppVersion(): string {
|
||||
return APP_VERSION
|
||||
}
|
||||
|
||||
export function parseAppVersion(version: string): number[] {
|
||||
return version
|
||||
.replace(/^v/i, '')
|
||||
.split('.')
|
||||
.map((part) => Number.parseInt(part, 10) || 0)
|
||||
}
|
||||
|
||||
/** Positive when `a` is newer than `b`. */
|
||||
export function compareAppVersions(a: string, b: string): number {
|
||||
const partsA = parseAppVersion(a)
|
||||
const partsB = parseAppVersion(b)
|
||||
const length = Math.max(partsA.length, partsB.length)
|
||||
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const diff = (partsA[index] ?? 0) - (partsB[index] ?? 0)
|
||||
if (diff !== 0) return diff
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
export function isNewerAppVersion(serverVersion: string, clientVersion: string): boolean {
|
||||
return compareAppVersions(serverVersion, clientVersion) > 0
|
||||
}
|
||||
|
||||
export async function fetchDeployedVersion(timeoutMs = 4_000): Promise<string | null> {
|
||||
if (!navigator.onLine) return null
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/version.json?_=${Date.now()}`, {
|
||||
cache: 'no-store',
|
||||
signal: controller.signal
|
||||
})
|
||||
if (!response.ok) return null
|
||||
|
||||
const payload = (await response.json()) as { version?: unknown }
|
||||
return typeof payload.version === 'string' ? payload.version.trim() : null
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
export async function isDeployedVersionNewer(): Promise<boolean> {
|
||||
const deployedVersion = await fetchDeployedVersion()
|
||||
if (!deployedVersion) return false
|
||||
return isNewerAppVersion(deployedVersion, getAppVersion())
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { tryDecryptEntryPayload } from './quickEventLog.js'
|
||||
|
||||
vi.mock('./crypto.js', () => ({
|
||||
decryptJson: vi.fn(async (_c: string, _i: string, _t: string) => {
|
||||
throw new Error('decrypt failed')
|
||||
}),
|
||||
encryptJson: vi.fn()
|
||||
}))
|
||||
|
||||
describe('tryDecryptEntryPayload', () => {
|
||||
it('returns null when decryption fails', async () => {
|
||||
const result = await tryDecryptEntryPayload(
|
||||
{ encryptedData: 'x', iv: 'y', tag: 'z' },
|
||||
new ArrayBuffer(32)
|
||||
)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,388 @@
|
||||
import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { ensureLogbookKey, getLogbookKey } from './logbookKeys.js'
|
||||
import { decryptJson, encryptJson } from './crypto.js'
|
||||
import { syncLogbook } from './sync.js'
|
||||
import {
|
||||
buildLogEntryPayload,
|
||||
normalizeLogEvent,
|
||||
sortLogEventsByTime,
|
||||
currentLocalTimeHHMM,
|
||||
type LogEventPayload
|
||||
} from '../utils/logEntryPayload.js'
|
||||
import {
|
||||
carryOverFromPreviousDay,
|
||||
compareTravelDaysChronological,
|
||||
getNextTravelDayNumber,
|
||||
type LogEntryTankSource,
|
||||
type TravelDaySortable
|
||||
} from '../utils/logEntryTankLevels.js'
|
||||
|
||||
export interface LoadedEntry {
|
||||
payloadId: string
|
||||
updatedAt: string
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
|
||||
type EncryptedRecord = {
|
||||
encryptedData: string
|
||||
iv: string
|
||||
tag: string
|
||||
}
|
||||
|
||||
async function getMasterKey(logbookId: string): Promise<ArrayBuffer> {
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
return masterKey
|
||||
}
|
||||
|
||||
/** Decrypt one record; skip corrupt or legacy entries instead of aborting the whole scan. */
|
||||
export async function tryDecryptEntryPayload(
|
||||
record: EncryptedRecord,
|
||||
key: ArrayBuffer
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
try {
|
||||
return await decryptJson(record.encryptedData, record.iv, record.tag, key)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function sortEntriesNewestFirst<T extends { updatedAt: string }>(entries: T[]): T[] {
|
||||
return [...entries].sort(
|
||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||
)
|
||||
}
|
||||
|
||||
function tankLevelsFromData(data: Record<string, unknown>) {
|
||||
const fw = (data.freshwater as Record<string, number> | undefined) ?? {
|
||||
morning: 0, refilled: 0, evening: 0, consumption: 0
|
||||
}
|
||||
const fuel = (data.fuel as Record<string, number> | undefined) ?? {
|
||||
morning: 0, refilled: 0, evening: 0, consumption: 0
|
||||
}
|
||||
const gw = data.greywater as { level?: number } | undefined
|
||||
return { fw, fuel, gw }
|
||||
}
|
||||
|
||||
function buildEncryptedPayload(
|
||||
data: Record<string, unknown>,
|
||||
options: {
|
||||
events: LogEventPayload[]
|
||||
departure?: string
|
||||
destination?: string
|
||||
freshwater?: { morning: number; refilled: number; evening: number; consumption: number }
|
||||
fuel?: { morning: number; refilled: number; evening: number; consumption: number }
|
||||
clearSignatures?: boolean
|
||||
}
|
||||
): Record<string, unknown> {
|
||||
const { fw, fuel, gw } = tankLevelsFromData(data)
|
||||
const trackDistance = data.trackDistanceNm
|
||||
const trackSpeedMax = data.trackSpeedMaxKn
|
||||
const trackSpeedAvg = data.trackSpeedAvgKn
|
||||
const motorHoursRaw = data.motorHours
|
||||
|
||||
const freshwater = options.freshwater ?? {
|
||||
morning: fw.morning || 0,
|
||||
refilled: fw.refilled || 0,
|
||||
evening: fw.evening || 0,
|
||||
consumption: fw.consumption ?? 0
|
||||
}
|
||||
const fuelLevels = options.fuel ?? {
|
||||
morning: fuel.morning || 0,
|
||||
refilled: fuel.refilled || 0,
|
||||
evening: fuel.evening || 0,
|
||||
consumption: fuel.consumption ?? 0
|
||||
}
|
||||
|
||||
const payload = buildLogEntryPayload({
|
||||
date: String(data.date || ''),
|
||||
dayOfTravel: String(data.dayOfTravel || ''),
|
||||
departure: options.departure ?? String(data.departure || ''),
|
||||
destination: options.destination ?? String(data.destination || ''),
|
||||
freshwater,
|
||||
fuel: fuelLevels,
|
||||
greywater: gw ? { level: gw.level || 0 } : undefined,
|
||||
trackDistanceNm:
|
||||
trackDistance != null && trackDistance !== ''
|
||||
? parseFloat(String(trackDistance))
|
||||
: undefined,
|
||||
trackSpeedMaxKn:
|
||||
trackSpeedMax != null && trackSpeedMax !== ''
|
||||
? parseFloat(String(trackSpeedMax))
|
||||
: undefined,
|
||||
trackSpeedAvgKn:
|
||||
trackSpeedAvg != null && trackSpeedAvg !== ''
|
||||
? parseFloat(String(trackSpeedAvg))
|
||||
: undefined,
|
||||
motorHours:
|
||||
motorHoursRaw != null && motorHoursRaw !== ''
|
||||
? parseFloat(String(motorHoursRaw))
|
||||
: undefined,
|
||||
events: options.events
|
||||
})
|
||||
|
||||
const clear = options.clearSignatures
|
||||
return {
|
||||
...payload,
|
||||
signSkipper: clear ? '' : (data.signSkipper ?? ''),
|
||||
signCrew: clear ? '' : (data.signCrew ?? '')
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadEntry(logbookId: string, entryId: string): Promise<LoadedEntry | null> {
|
||||
const masterKey = await getMasterKey(logbookId)
|
||||
const record = await db.entries.get(entryId)
|
||||
if (!record) return null
|
||||
const data = await tryDecryptEntryPayload(record, masterKey)
|
||||
if (!data) return null
|
||||
return { payloadId: record.payloadId, updatedAt: record.updatedAt, data }
|
||||
}
|
||||
|
||||
export async function findTodayEntryId(logbookId: string): Promise<string | null> {
|
||||
const todayStr = new Date().toISOString().substring(0, 10)
|
||||
const masterKey = await getMasterKey(logbookId)
|
||||
const local = sortEntriesNewestFirst(await db.entries.where({ logbookId }).toArray())
|
||||
|
||||
for (const entry of local) {
|
||||
const decrypted = await tryDecryptEntryPayload(entry, masterKey)
|
||||
if (decrypted && String(decrypted.date) === todayStr) {
|
||||
return entry.payloadId
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function createTodayEntry(logbookId: string): Promise<string> {
|
||||
const masterKey = await getMasterKey(logbookId)
|
||||
const localEntries = await db.entries.where({ logbookId }).toArray()
|
||||
const decryptedEntries: Array<LogEntryTankSource & TravelDaySortable> = []
|
||||
|
||||
if (localEntries.length > 0) {
|
||||
for (const entry of localEntries) {
|
||||
const decrypted = await tryDecryptEntryPayload(entry, masterKey)
|
||||
if (decrypted) {
|
||||
decryptedEntries.push(decrypted as LogEntryTankSource & TravelDaySortable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
decryptedEntries.sort(compareTravelDaysChronological)
|
||||
const previousEntry = decryptedEntries.at(-1) ?? null
|
||||
const { freshwater, fuel, greywaterLevel, departure } = carryOverFromPreviousDay(previousEntry)
|
||||
|
||||
const localId = window.crypto.randomUUID()
|
||||
const nowStr = new Date().toISOString()
|
||||
const todayStr = nowStr.substring(0, 10)
|
||||
|
||||
const initialPayload = {
|
||||
date: todayStr,
|
||||
dayOfTravel: getNextTravelDayNumber(decryptedEntries),
|
||||
departure,
|
||||
destination: '',
|
||||
freshwater,
|
||||
fuel,
|
||||
...(greywaterLevel > 0 ? { greywater: { level: greywaterLevel } } : {}),
|
||||
signSkipper: '',
|
||||
signCrew: '',
|
||||
events: []
|
||||
}
|
||||
|
||||
const encrypted = await encryptJson(initialPayload, masterKey)
|
||||
|
||||
await db.entries.put({
|
||||
payloadId: localId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: nowStr
|
||||
})
|
||||
|
||||
await db.syncQueue.put({
|
||||
action: 'create',
|
||||
type: 'entry',
|
||||
payloadId: localId,
|
||||
logbookId,
|
||||
data: JSON.stringify(encrypted),
|
||||
updatedAt: nowStr
|
||||
})
|
||||
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
return localId
|
||||
}
|
||||
|
||||
export async function findOrCreateTodayEntry(logbookId: string): Promise<string> {
|
||||
const id = logbookId.trim()
|
||||
if (!id) throw new Error('Logbook id required')
|
||||
|
||||
await ensureLogbookKey(id)
|
||||
|
||||
const entryCount = await db.entries.where({ logbookId: id }).count()
|
||||
if (entryCount === 0) {
|
||||
return createTodayEntry(id)
|
||||
}
|
||||
|
||||
const existing = await findTodayEntryId(id)
|
||||
if (existing) return existing
|
||||
return createTodayEntry(id)
|
||||
}
|
||||
|
||||
export interface AppendQuickEventResult {
|
||||
events: LogEventPayload[]
|
||||
hadSignature: boolean
|
||||
}
|
||||
|
||||
export async function appendQuickEvent(
|
||||
logbookId: string,
|
||||
entryId: string,
|
||||
partialEvent: Partial<LogEventPayload>,
|
||||
headerPatch?: { departure?: string; destination?: string }
|
||||
): Promise<AppendQuickEventResult> {
|
||||
const loaded = await loadEntry(logbookId, entryId)
|
||||
if (!loaded) throw new Error('Entry not found')
|
||||
|
||||
const hadSignature = !!(loaded.data.signSkipper || loaded.data.signCrew)
|
||||
const currentEvents = (loaded.data.events as LogEventPayload[]) || []
|
||||
const newEvent = normalizeLogEvent({
|
||||
time: currentLocalTimeHHMM(),
|
||||
...partialEvent
|
||||
})
|
||||
const nextEvents = sortLogEventsByTime([...currentEvents, newEvent])
|
||||
|
||||
await persistEntry(logbookId, entryId, loaded.data, {
|
||||
events: nextEvents,
|
||||
departure: headerPatch?.departure,
|
||||
destination: headerPatch?.destination,
|
||||
clearSignatures: hadSignature
|
||||
})
|
||||
|
||||
return { events: nextEvents, hadSignature }
|
||||
}
|
||||
|
||||
/** Append multiple events in one load/encrypt/persist cycle (avoids UI freezes). */
|
||||
export async function appendQuickEvents(
|
||||
logbookId: string,
|
||||
entryId: string,
|
||||
partialEvents: Partial<LogEventPayload>[]
|
||||
): Promise<AppendQuickEventResult> {
|
||||
const loaded = await loadEntry(logbookId, entryId)
|
||||
if (!loaded) throw new Error('Entry not found')
|
||||
|
||||
const hadSignature = !!(loaded.data.signSkipper || loaded.data.signCrew)
|
||||
const currentEvents = (loaded.data.events as LogEventPayload[]) || []
|
||||
if (partialEvents.length === 0) {
|
||||
return { events: currentEvents, hadSignature }
|
||||
}
|
||||
|
||||
const time = currentLocalTimeHHMM()
|
||||
const newEvents = partialEvents.map((partial) =>
|
||||
normalizeLogEvent({ time, ...partial })
|
||||
)
|
||||
const nextEvents = sortLogEventsByTime([...currentEvents, ...newEvents])
|
||||
|
||||
await persistEntry(logbookId, entryId, loaded.data, {
|
||||
events: nextEvents,
|
||||
clearSignatures: hadSignature
|
||||
})
|
||||
|
||||
return { events: nextEvents, hadSignature }
|
||||
}
|
||||
|
||||
async function persistEntry(
|
||||
logbookId: string,
|
||||
entryId: string,
|
||||
data: Record<string, unknown>,
|
||||
options: Parameters<typeof buildEncryptedPayload>[1]
|
||||
): Promise<void> {
|
||||
const hadSignature = !!(data.signSkipper || data.signCrew)
|
||||
const entryData = buildEncryptedPayload(data, {
|
||||
...options,
|
||||
clearSignatures: options.clearSignatures ?? hadSignature
|
||||
})
|
||||
|
||||
const masterKey = await getMasterKey(logbookId)
|
||||
const encrypted = await encryptJson(entryData, masterKey)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
await db.entries.put({
|
||||
payloadId: entryId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
await db.syncQueue.put({
|
||||
action: 'update',
|
||||
type: 'entry',
|
||||
payloadId: entryId,
|
||||
logbookId,
|
||||
data: JSON.stringify(encrypted),
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
}
|
||||
|
||||
export async function removeLastEvent(
|
||||
logbookId: string,
|
||||
entryId: string
|
||||
): Promise<LogEventPayload[]> {
|
||||
const loaded = await loadEntry(logbookId, entryId)
|
||||
if (!loaded) throw new Error('Entry not found')
|
||||
|
||||
const currentEvents = (loaded.data.events as LogEventPayload[]) || []
|
||||
if (currentEvents.length === 0) return []
|
||||
|
||||
const nextEvents = sortLogEventsByTime(currentEvents.slice(0, -1))
|
||||
await persistEntry(logbookId, entryId, loaded.data, { events: nextEvents })
|
||||
return nextEvents
|
||||
}
|
||||
|
||||
export async function appendTankRefill(
|
||||
logbookId: string,
|
||||
entryId: string,
|
||||
tank: 'fuel' | 'freshwater',
|
||||
addLiters: number,
|
||||
event: Partial<LogEventPayload>
|
||||
): Promise<AppendQuickEventResult> {
|
||||
const loaded = await loadEntry(logbookId, entryId)
|
||||
if (!loaded) throw new Error('Entry not found')
|
||||
|
||||
const { fw, fuel } = tankLevelsFromData(loaded.data)
|
||||
const currentEvents = (loaded.data.events as LogEventPayload[]) || []
|
||||
const newEvent = normalizeLogEvent({
|
||||
time: currentLocalTimeHHMM(),
|
||||
...event
|
||||
})
|
||||
const nextEvents = sortLogEventsByTime([...currentEvents, newEvent])
|
||||
|
||||
const tankPatch = tank === 'fuel'
|
||||
? {
|
||||
fuel: {
|
||||
morning: fuel.morning || 0,
|
||||
refilled: (fuel.refilled || 0) + addLiters,
|
||||
evening: fuel.evening || 0,
|
||||
consumption: fuel.consumption ?? 0
|
||||
}
|
||||
}
|
||||
: {
|
||||
freshwater: {
|
||||
morning: fw.morning || 0,
|
||||
refilled: (fw.refilled || 0) + addLiters,
|
||||
evening: fw.evening || 0,
|
||||
consumption: fw.consumption ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
const hadSignature = !!(loaded.data.signSkipper || loaded.data.signCrew)
|
||||
await persistEntry(logbookId, entryId, loaded.data, {
|
||||
events: nextEvents,
|
||||
...tankPatch,
|
||||
clearSignatures: hadSignature
|
||||
})
|
||||
|
||||
return { events: nextEvents, hadSignature }
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { getLogbookAccess } from './logbookAccess.js'
|
||||
const API_BASE = '/api/sync'
|
||||
const syncingLogbooks = new Set<string>()
|
||||
const pendingResync = new Set<string>()
|
||||
let syncAllInFlight = 0
|
||||
|
||||
let isSyncing = false
|
||||
const listeners = new Set<(syncing: boolean) => void>()
|
||||
@@ -18,7 +19,8 @@ export function subscribeToSyncState(listener: (syncing: boolean) => void) {
|
||||
}
|
||||
}
|
||||
|
||||
function setSyncing(syncing: boolean) {
|
||||
function recomputeSyncingState() {
|
||||
const syncing = syncingLogbooks.size > 0 || syncAllInFlight > 0
|
||||
if (isSyncing !== syncing) {
|
||||
isSyncing = syncing
|
||||
listeners.forEach((l) => l(isSyncing))
|
||||
@@ -205,6 +207,54 @@ async function flushPushQueue(logbookId: string): Promise<boolean> {
|
||||
return ok
|
||||
}
|
||||
|
||||
type PulledServerPayload = {
|
||||
yacht?: { updatedAt: string } | null
|
||||
deviation?: { updatedAt: string } | null
|
||||
crews?: Array<{ payloadId: string; updatedAt: string }>
|
||||
entries?: Array<{ payloadId: string; updatedAt: string }>
|
||||
photos?: Array<{ payloadId: string; updatedAt: string }>
|
||||
gpsTracks?: Array<{ entryId: string; updatedAt: string }>
|
||||
}
|
||||
|
||||
/** Drop queue rows already reflected on the server (e.g. after direct API save). */
|
||||
async function pruneAcknowledgedQueueItems(
|
||||
logbookId: string,
|
||||
server: PulledServerPayload
|
||||
): Promise<void> {
|
||||
const pending = await db.syncQueue.where({ logbookId }).toArray()
|
||||
if (pending.length === 0) return
|
||||
|
||||
const serverTimes = new Map<string, string>()
|
||||
if (server.yacht) serverTimes.set('yacht:' + logbookId, server.yacht.updatedAt)
|
||||
if (server.deviation) serverTimes.set('deviation:' + logbookId, server.deviation.updatedAt)
|
||||
for (const c of server.crews ?? []) serverTimes.set('crew:' + c.payloadId, c.updatedAt)
|
||||
for (const e of server.entries ?? []) serverTimes.set('entry:' + e.payloadId, e.updatedAt)
|
||||
for (const p of server.photos ?? []) serverTimes.set('photo:' + p.payloadId, p.updatedAt)
|
||||
for (const gt of server.gpsTracks ?? []) serverTimes.set('gpsTrack:' + gt.entryId, gt.updatedAt)
|
||||
|
||||
const localLogbook = await db.logbooks.get(logbookId)
|
||||
const staleIds: number[] = []
|
||||
|
||||
for (const item of pending) {
|
||||
if (item.type === 'logbook') {
|
||||
if (localLogbook?.isSynced === 1) {
|
||||
if (item.id !== undefined) staleIds.push(item.id)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const key = item.type === 'yacht' ? 'yacht:' + logbookId : `${item.type}:${item.payloadId}`
|
||||
const serverUpdatedAt = serverTimes.get(key)
|
||||
if (serverUpdatedAt && !isNewer(item.updatedAt, serverUpdatedAt)) {
|
||||
if (item.id !== undefined) staleIds.push(item.id)
|
||||
}
|
||||
}
|
||||
|
||||
if (staleIds.length > 0) {
|
||||
await db.syncQueue.bulkDelete(staleIds)
|
||||
}
|
||||
}
|
||||
|
||||
// Pull updates from the server and apply last-write-wins
|
||||
async function pullChanges(logbookId: string): Promise<boolean> {
|
||||
if (!localStorage.getItem('active_userid')) return false
|
||||
@@ -220,6 +270,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
const { yacht, deviation, crews, entries, photos, gpsTracks } = await response.json()
|
||||
const serverSnapshot: PulledServerPayload = { yacht, deviation, crews, entries, photos, gpsTracks }
|
||||
|
||||
// 1. Sync Yacht Payload
|
||||
if (yacht) {
|
||||
@@ -380,6 +431,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
await pruneAcknowledgedQueueItems(logbookId, serverSnapshot)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Error during sync pull:', error)
|
||||
@@ -400,7 +452,7 @@ export async function syncLogbook(logbookId: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
syncingLogbooks.add(logbookId)
|
||||
setSyncing(true)
|
||||
recomputeSyncingState()
|
||||
|
||||
try {
|
||||
const pushed = await flushPushQueue(logbookId)
|
||||
@@ -410,7 +462,7 @@ export async function syncLogbook(logbookId: string): Promise<boolean> {
|
||||
return pushed && pulled && pushedAfterPull
|
||||
} finally {
|
||||
syncingLogbooks.delete(logbookId)
|
||||
setSyncing(syncingLogbooks.size > 0)
|
||||
recomputeSyncingState()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,8 +473,9 @@ export async function syncAllLogbooks(): Promise<void> {
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) return
|
||||
|
||||
syncAllInFlight++
|
||||
recomputeSyncingState()
|
||||
try {
|
||||
setSyncing(true)
|
||||
// 1. Fetch latest logbook lists first (synchronizes db.logbooks index)
|
||||
const logbooks = await db.logbooks.toArray()
|
||||
|
||||
@@ -446,7 +499,8 @@ export async function syncAllLogbooks(): Promise<void> {
|
||||
} catch (error) {
|
||||
console.error('Error synchronizing all logbooks:', error)
|
||||
} finally {
|
||||
setSyncing(syncingLogbooks.size > 0)
|
||||
syncAllInFlight = Math.max(0, syncAllInFlight - 1)
|
||||
recomputeSyncingState()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getColorSchemePreference,
|
||||
getOwmApiKey,
|
||||
getOwmApiKeyForActiveUser,
|
||||
getThemePreference,
|
||||
setColorSchemePreference,
|
||||
setOwmApiKey,
|
||||
setThemePreference
|
||||
} from './userPreferences.js'
|
||||
|
||||
const USER_ID = 'test-user-123'
|
||||
|
||||
describe('userPreferences', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('migrates legacy theme and color scheme keys on first read', () => {
|
||||
localStorage.setItem('active_userid', USER_ID)
|
||||
localStorage.setItem('active_theme', 'material')
|
||||
localStorage.setItem('active_color_scheme', 'dark')
|
||||
|
||||
expect(getThemePreference()).toBe('material')
|
||||
expect(getColorSchemePreference()).toBe('dark')
|
||||
expect(localStorage.getItem(`user_pref_theme_${USER_ID}`)).toBe('material')
|
||||
expect(localStorage.getItem(`user_pref_color_scheme_${USER_ID}`)).toBe('dark')
|
||||
})
|
||||
|
||||
it('stores OWM key per user', () => {
|
||||
setOwmApiKey(USER_ID, 'secret-key')
|
||||
expect(getOwmApiKey(USER_ID)).toBe('secret-key')
|
||||
setOwmApiKey(USER_ID, ' ')
|
||||
expect(getOwmApiKey(USER_ID)).toBe('')
|
||||
})
|
||||
|
||||
it('reads namespaced OWM key via active user id', () => {
|
||||
setOwmApiKey(USER_ID, 'namespaced-only')
|
||||
localStorage.setItem('active_userid', USER_ID)
|
||||
localStorage.removeItem('owm_api_key')
|
||||
|
||||
expect(getOwmApiKeyForActiveUser()).toBe('namespaced-only')
|
||||
expect(getOwmApiKey()).toBe('namespaced-only')
|
||||
})
|
||||
|
||||
it('does not read namespaced OWM key without active user id', () => {
|
||||
setOwmApiKey(USER_ID, 'namespaced-only')
|
||||
localStorage.removeItem('active_userid')
|
||||
localStorage.removeItem('owm_api_key')
|
||||
|
||||
expect(getOwmApiKeyForActiveUser()).toBe('')
|
||||
expect(getOwmApiKey()).toBe('')
|
||||
})
|
||||
|
||||
it('writes theme preferences to namespaced keys', () => {
|
||||
setThemePreference(USER_ID, 'ocean')
|
||||
setColorSchemePreference(USER_ID, 'light')
|
||||
expect(getThemePreference(USER_ID)).toBe('ocean')
|
||||
expect(getColorSchemePreference(USER_ID)).toBe('light')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
const LEGACY_THEME = 'active_theme'
|
||||
const LEGACY_COLOR_SCHEME = 'active_color_scheme'
|
||||
const LEGACY_OWM_KEY = 'owm_api_key'
|
||||
|
||||
function themeKey(userId: string): string {
|
||||
return `user_pref_theme_${userId}`
|
||||
}
|
||||
|
||||
function colorSchemeKey(userId: string): string {
|
||||
return `user_pref_color_scheme_${userId}`
|
||||
}
|
||||
|
||||
function owmKey(userId: string): string {
|
||||
return `user_pref_owm_api_key_${userId}`
|
||||
}
|
||||
|
||||
export function getActiveUserId(): string | null {
|
||||
return localStorage.getItem('active_userid')
|
||||
}
|
||||
|
||||
function migrateLegacyPrefs(userId: string): void {
|
||||
const pairs: Array<{ namespaced: string; legacy: string }> = [
|
||||
{ namespaced: themeKey(userId), legacy: LEGACY_THEME },
|
||||
{ namespaced: colorSchemeKey(userId), legacy: LEGACY_COLOR_SCHEME },
|
||||
{ namespaced: owmKey(userId), legacy: LEGACY_OWM_KEY }
|
||||
]
|
||||
|
||||
for (const { namespaced, legacy } of pairs) {
|
||||
if (localStorage.getItem(namespaced) != null) continue
|
||||
const value = localStorage.getItem(legacy)
|
||||
if (value != null) {
|
||||
localStorage.setItem(namespaced, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveUserId(userId?: string | null): string | null {
|
||||
const id = (userId?.trim() || getActiveUserId()?.trim()) || null
|
||||
if (!id) return null
|
||||
migrateLegacyPrefs(id)
|
||||
return id
|
||||
}
|
||||
|
||||
export function getThemePreference(userId?: string | null): string {
|
||||
const id = resolveUserId(userId)
|
||||
if (id) {
|
||||
return localStorage.getItem(themeKey(id)) ?? localStorage.getItem(LEGACY_THEME) ?? 'auto'
|
||||
}
|
||||
return localStorage.getItem(LEGACY_THEME) ?? 'auto'
|
||||
}
|
||||
|
||||
export function setThemePreference(userId: string, value: string): void {
|
||||
migrateLegacyPrefs(userId)
|
||||
localStorage.setItem(themeKey(userId), value)
|
||||
}
|
||||
|
||||
export function getColorSchemePreference(userId?: string | null): string {
|
||||
const id = resolveUserId(userId)
|
||||
if (id) {
|
||||
return localStorage.getItem(colorSchemeKey(id)) ?? localStorage.getItem(LEGACY_COLOR_SCHEME) ?? 'auto'
|
||||
}
|
||||
return localStorage.getItem(LEGACY_COLOR_SCHEME) ?? 'auto'
|
||||
}
|
||||
|
||||
export function setColorSchemePreference(userId: string, value: string): void {
|
||||
migrateLegacyPrefs(userId)
|
||||
localStorage.setItem(colorSchemeKey(userId), value)
|
||||
}
|
||||
|
||||
export function getOwmApiKey(userId?: string | null): string {
|
||||
const id = resolveUserId(userId)
|
||||
if (id) {
|
||||
return localStorage.getItem(owmKey(id)) ?? localStorage.getItem(LEGACY_OWM_KEY) ?? ''
|
||||
}
|
||||
return localStorage.getItem(LEGACY_OWM_KEY) ?? ''
|
||||
}
|
||||
|
||||
/** OWM key for the signed-in user (`active_userid`). Prefer this over a bare `getOwmApiKey()` call. */
|
||||
export function getOwmApiKeyForActiveUser(): string {
|
||||
return getOwmApiKey(getActiveUserId())
|
||||
}
|
||||
|
||||
export function setOwmApiKey(userId: string, value: string): void {
|
||||
migrateLegacyPrefs(userId)
|
||||
const trimmed = value.trim()
|
||||
if (trimmed) {
|
||||
localStorage.setItem(owmKey(userId), trimmed)
|
||||
} else {
|
||||
localStorage.removeItem(owmKey(userId))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PlausibleEvents } from './analytics.js'
|
||||
|
||||
const apiFetch = vi.fn()
|
||||
const trackPlausibleEvent = vi.fn()
|
||||
|
||||
vi.mock('./api.js', () => ({ apiFetch }))
|
||||
vi.mock('./analytics.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./analytics.js')>()
|
||||
return {
|
||||
...actual,
|
||||
trackPlausibleEvent: (...args: unknown[]) => trackPlausibleEvent(...args)
|
||||
}
|
||||
})
|
||||
vi.mock('./userPreferences.js', () => ({
|
||||
getOwmApiKeyForActiveUser: () => ''
|
||||
}))
|
||||
|
||||
describe('fetchOpenWeatherCurrent', () => {
|
||||
beforeEach(() => {
|
||||
apiFetch.mockReset()
|
||||
trackPlausibleEvent.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('tracks OWM Weather Fetched on success when analyticsSource is set', async () => {
|
||||
apiFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ coord: { lat: 54, lon: 10 }, main: { temp: 20 } })
|
||||
})
|
||||
|
||||
const { fetchOpenWeatherCurrent } = await import('./weather.js')
|
||||
await fetchOpenWeatherCurrent(
|
||||
{ lat: '54.0', lon: '10.0' },
|
||||
{ analyticsSource: 'live_log' }
|
||||
)
|
||||
|
||||
expect(trackPlausibleEvent).toHaveBeenCalledWith(PlausibleEvents.OWM_WEATHER_FETCHED, {
|
||||
source: 'live_log'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not track when the API request fails', async () => {
|
||||
apiFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({ error: 'fail' })
|
||||
})
|
||||
|
||||
const { fetchOpenWeatherCurrent, WeatherApiError } = await import('./weather.js')
|
||||
await expect(
|
||||
fetchOpenWeatherCurrent({ lat: '54', lon: '10' }, { analyticsSource: 'entry_editor' })
|
||||
).rejects.toBeInstanceOf(WeatherApiError)
|
||||
|
||||
expect(trackPlausibleEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,10 @@
|
||||
import { apiFetch } from './api.js'
|
||||
import { getOwmApiKeyForActiveUser } from './userPreferences.js'
|
||||
import {
|
||||
type OwmAnalyticsSource,
|
||||
PlausibleEvents,
|
||||
trackPlausibleEvent
|
||||
} from './analytics.js'
|
||||
|
||||
export class WeatherApiError extends Error {
|
||||
code: 'NO_KEY' | 'REQUEST_FAILED'
|
||||
@@ -10,11 +16,16 @@ export class WeatherApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchOpenWeatherCurrent(params: {
|
||||
lat?: string
|
||||
lon?: string
|
||||
q?: string
|
||||
}): Promise<Record<string, unknown>> {
|
||||
const OWM_FETCH_TIMEOUT_MS = 20_000
|
||||
|
||||
export async function fetchOpenWeatherCurrent(
|
||||
params: {
|
||||
lat?: string
|
||||
lon?: string
|
||||
q?: string
|
||||
},
|
||||
options?: { analyticsSource: OwmAnalyticsSource }
|
||||
): Promise<Record<string, unknown>> {
|
||||
const searchParams = new URLSearchParams()
|
||||
|
||||
if (params.lat && params.lon) {
|
||||
@@ -26,11 +37,26 @@ export async function fetchOpenWeatherCurrent(params: {
|
||||
throw new WeatherApiError('lat/lon or location query required')
|
||||
}
|
||||
|
||||
const userKey = localStorage.getItem('owm_api_key')?.trim()
|
||||
const userKey = getOwmApiKeyForActiveUser().trim()
|
||||
const headers: Record<string, string> = {}
|
||||
if (userKey) headers['X-OWM-Api-Key'] = userKey
|
||||
|
||||
const res = await apiFetch(`/api/weather/current?${searchParams.toString()}`, { headers })
|
||||
const controller = new AbortController()
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), OWM_FETCH_TIMEOUT_MS)
|
||||
let res: Response
|
||||
try {
|
||||
res = await apiFetch(`/api/weather/current?${searchParams.toString()}`, {
|
||||
headers,
|
||||
signal: controller.signal
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
throw new WeatherApiError('Weather request timed out')
|
||||
}
|
||||
throw err
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId)
|
||||
}
|
||||
|
||||
if (res.status === 503) {
|
||||
throw new WeatherApiError('No OpenWeatherMap API key configured', 'NO_KEY')
|
||||
@@ -41,5 +67,11 @@ export async function fetchOpenWeatherCurrent(params: {
|
||||
throw new WeatherApiError('Weather API rejected the request')
|
||||
}
|
||||
|
||||
if (options?.analyticsSource) {
|
||||
trackPlausibleEvent(PlausibleEvents.OWM_WEATHER_FETCHED, {
|
||||
source: options.analyticsSource
|
||||
})
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
+28
-1
@@ -1,10 +1,37 @@
|
||||
/// <reference lib="webworker" />
|
||||
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching'
|
||||
import { clientsClaim } from 'workbox-core'
|
||||
import { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute } from 'workbox-precaching'
|
||||
import { registerRoute } from 'workbox-routing'
|
||||
import { NetworkFirst, NetworkOnly } from 'workbox-strategies'
|
||||
|
||||
declare let self: ServiceWorkerGlobalScope
|
||||
|
||||
const appShellFallback = createHandlerBoundToURL('/index.html')
|
||||
const navigationStrategy = new NetworkFirst({
|
||||
cacheName: 'app-shell',
|
||||
networkTimeoutSeconds: 3
|
||||
})
|
||||
|
||||
registerRoute(({ request }) => request.mode === 'navigate', async (context) => {
|
||||
try {
|
||||
return await navigationStrategy.handle(context)
|
||||
} catch {
|
||||
return appShellFallback(context)
|
||||
}
|
||||
})
|
||||
|
||||
precacheAndRoute(self.__WB_MANIFEST)
|
||||
cleanupOutdatedCaches()
|
||||
clientsClaim()
|
||||
|
||||
// Always fetch the live deploy version, even under an older precache.
|
||||
registerRoute(({ url }) => url.pathname === '/version.json', new NetworkOnly())
|
||||
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data?.type === 'SKIP_WAITING') {
|
||||
void self.skipWaiting()
|
||||
}
|
||||
})
|
||||
|
||||
interface PushPayload {
|
||||
title?: string
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
/* Fallback before JS hydrates (ocean · dark) */
|
||||
html {
|
||||
color-scheme: dark;
|
||||
--app-theme-color: #0b0c10;
|
||||
--app-body-bg: radial-gradient(circle at center, #1b264f 0%, #0b0c10 100%);
|
||||
--app-text: #f1f5f9;
|
||||
--app-text-heading: #f8fafc;
|
||||
@@ -61,6 +62,7 @@ html {
|
||||
/* ===== OCEAN · DARK (default) ===== */
|
||||
html.scheme-dark.theme-ocean {
|
||||
color-scheme: dark;
|
||||
--app-theme-color: #0b0c10;
|
||||
--app-body-bg: radial-gradient(circle at center, #1b264f 0%, #0b0c10 100%);
|
||||
--app-text: #f1f5f9;
|
||||
--app-text-heading: #f8fafc;
|
||||
@@ -116,6 +118,7 @@ html.scheme-dark.theme-ocean {
|
||||
/* ===== OCEAN · LIGHT ===== */
|
||||
html.scheme-light.theme-ocean {
|
||||
color-scheme: light;
|
||||
--app-theme-color: #e2e8f0;
|
||||
--app-body-bg: linear-gradient(165deg, #dbeafe 0%, #f8fafc 42%, #e2e8f0 100%);
|
||||
--app-text: #1e293b;
|
||||
--app-text-heading: #0f172a;
|
||||
@@ -171,6 +174,7 @@ html.scheme-light.theme-ocean {
|
||||
/* ===== MATERIAL · DARK ===== */
|
||||
html.scheme-dark.theme-material {
|
||||
color-scheme: dark;
|
||||
--app-theme-color: #121212;
|
||||
--app-body-bg: #121212;
|
||||
--app-text: #f1f5f9;
|
||||
--app-text-heading: #f8fafc;
|
||||
@@ -226,6 +230,7 @@ html.scheme-dark.theme-material {
|
||||
/* ===== MATERIAL · LIGHT ===== */
|
||||
html.scheme-light.theme-material {
|
||||
color-scheme: light;
|
||||
--app-theme-color: #fafafa;
|
||||
--app-body-bg: #fafafa;
|
||||
--app-text: #212121;
|
||||
--app-text-heading: #111827;
|
||||
@@ -281,6 +286,7 @@ html.scheme-light.theme-material {
|
||||
/* ===== CUPERTINO · DARK ===== */
|
||||
html.scheme-dark.theme-cupertino {
|
||||
color-scheme: dark;
|
||||
--app-theme-color: #000000;
|
||||
--app-body-bg: #000000;
|
||||
--app-text: #ffffff;
|
||||
--app-text-heading: #ffffff;
|
||||
@@ -336,6 +342,7 @@ html.scheme-dark.theme-cupertino {
|
||||
/* ===== CUPERTINO · LIGHT ===== */
|
||||
html.scheme-light.theme-cupertino {
|
||||
color-scheme: light;
|
||||
--app-theme-color: #f2f2f7;
|
||||
--app-body-bg: #f2f2f7;
|
||||
--app-text: #1c1c1e;
|
||||
--app-text-heading: #000000;
|
||||
@@ -396,3 +403,12 @@ html.scheme-light.theme-cupertino {
|
||||
html.scheme-light #root {
|
||||
border-inline-color: var(--app-border-subtle);
|
||||
}
|
||||
|
||||
/* Bridge legacy index.css tokens to appearance (avoids system color-scheme drift) */
|
||||
html.scheme-light,
|
||||
html.scheme-dark {
|
||||
--text: var(--app-text);
|
||||
--text-h: var(--app-text-heading);
|
||||
--code-bg: var(--app-icon-btn-bg);
|
||||
--border: var(--app-border-subtle);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { captureVideoFrame, preferNativeCameraPicker } from './captureVideoFrame.js'
|
||||
|
||||
describe('preferNativeCameraPicker', () => {
|
||||
it('returns true on Android user agents', () => {
|
||||
vi.stubGlobal('navigator', { ...navigator, userAgent: 'Mozilla/5.0 (Linux; Android 14)' })
|
||||
expect(preferNativeCameraPicker()).toBe(true)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('returns false on desktop without touch', () => {
|
||||
vi.stubGlobal('navigator', {
|
||||
...navigator,
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0)',
|
||||
maxTouchPoints: 0
|
||||
})
|
||||
vi.stubGlobal('matchMedia', () => ({
|
||||
matches: false,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {}
|
||||
}))
|
||||
Object.defineProperty(window, 'ontouchstart', { value: undefined, configurable: true })
|
||||
expect(preferNativeCameraPicker()).toBe(false)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
})
|
||||
|
||||
describe('captureVideoFrame', () => {
|
||||
it('throws when video dimensions are zero', async () => {
|
||||
const video = { videoWidth: 0, videoHeight: 0 } as HTMLVideoElement
|
||||
await expect(captureVideoFrame(video)).rejects.toThrow('video_frame_not_ready')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
/** Capture current video frame as JPEG blob (with Android-safe fallbacks). */
|
||||
export async function captureVideoFrame(video: HTMLVideoElement, quality = 0.92): Promise<Blob> {
|
||||
const width = video.videoWidth
|
||||
const height = video.videoHeight
|
||||
if (!width || !height) {
|
||||
throw new Error('video_frame_not_ready')
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) {
|
||||
throw new Error('canvas_context_unavailable')
|
||||
}
|
||||
ctx.drawImage(video, 0, 0, width, height)
|
||||
|
||||
const blob = await canvasToJpegBlob(canvas, quality)
|
||||
if (blob) return blob
|
||||
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', quality)
|
||||
const response = await fetch(dataUrl)
|
||||
const fallback = await response.blob()
|
||||
if (!fallback.size) {
|
||||
throw new Error('capture_encode_failed')
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
function canvasToJpegBlob(canvas: HTMLCanvasElement, quality: number): Promise<Blob | null> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false
|
||||
const finish = (blob: Blob | null) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
window.clearTimeout(timer)
|
||||
resolve(blob)
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => finish(null), 3000)
|
||||
|
||||
try {
|
||||
canvas.toBlob((blob) => finish(blob), 'image/jpeg', quality)
|
||||
} catch {
|
||||
finish(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Mobile: native camera via file input is more reliable than getUserMedia + canvas. */
|
||||
export function preferNativeCameraPicker(): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
const ua = navigator.userAgent
|
||||
if (/Android|iPhone|iPad|iPod/i.test(ua)) return true
|
||||
const touch = 'ontouchstart' in window || navigator.maxTouchPoints > 0
|
||||
const coarse = window.matchMedia('(pointer: coarse)').matches
|
||||
const narrow = window.matchMedia('(max-width: 768px)').matches
|
||||
return touch && (coarse || narrow)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
cardinalToDegrees,
|
||||
degreesToCardinal,
|
||||
formatCourseAngle,
|
||||
isCardinalDirection,
|
||||
normalizeCourseAngleString,
|
||||
normalizeWindDirectionString,
|
||||
parseCourseAngle,
|
||||
pointerAngleToDegrees,
|
||||
snapDegrees
|
||||
} from './courseAngle.js'
|
||||
|
||||
describe('parseCourseAngle', () => {
|
||||
it('parses padded and plain degrees', () => {
|
||||
expect(parseCourseAngle('042')).toBe(42)
|
||||
expect(parseCourseAngle('185°')).toBe(185)
|
||||
expect(parseCourseAngle('360')).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects invalid values', () => {
|
||||
expect(parseCourseAngle('999')).toBeNull()
|
||||
expect(parseCourseAngle('abc')).toBeNull()
|
||||
})
|
||||
|
||||
it('parses cardinal labels', () => {
|
||||
expect(parseCourseAngle('NW')).toBe(315)
|
||||
})
|
||||
})
|
||||
|
||||
describe('snapDegrees', () => {
|
||||
it('snaps to step', () => {
|
||||
expect(snapDegrees(47, 5)).toBe(45)
|
||||
expect(snapDegrees(358, 5)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cardinal helpers', () => {
|
||||
it('roundtrips cardinal through degrees', () => {
|
||||
expect(degreesToCardinal(225)).toBe('SW')
|
||||
expect(cardinalToDegrees('SW')).toBe(225)
|
||||
expect(isCardinalDirection('nne')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pointerAngleToDegrees', () => {
|
||||
it('returns 0 for north', () => {
|
||||
expect(pointerAngleToDegrees(100, 50, 100, 100)).toBe(0)
|
||||
})
|
||||
|
||||
it('returns 90 for east', () => {
|
||||
expect(Math.round(pointerAngleToDegrees(150, 100, 100, 100))).toBe(90)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeCourseAngleString', () => {
|
||||
it('keeps empty when allowed', () => {
|
||||
expect(normalizeCourseAngleString('', { allowEmpty: true })).toBe('')
|
||||
})
|
||||
|
||||
it('normalizes numeric course', () => {
|
||||
expect(normalizeCourseAngleString('042')).toBe('42')
|
||||
expect(formatCourseAngle(42, true)).toBe('042')
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeWindDirectionString', () => {
|
||||
it('preserves cardinal wind', () => {
|
||||
expect(normalizeWindDirectionString('nw')).toBe('NW')
|
||||
})
|
||||
|
||||
it('normalizes degree wind', () => {
|
||||
expect(normalizeWindDirectionString('090')).toBe('90')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
export const CARDINAL_DIRECTIONS = [
|
||||
'N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE',
|
||||
'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'
|
||||
] as const
|
||||
|
||||
export type CardinalDirection = (typeof CARDINAL_DIRECTIONS)[number]
|
||||
export type CourseStep = 1 | 5 | 10
|
||||
|
||||
const CARDINAL_SET = new Set<string>(CARDINAL_DIRECTIONS)
|
||||
|
||||
export function isCardinalDirection(value: string): boolean {
|
||||
return CARDINAL_SET.has(value.trim().toUpperCase())
|
||||
}
|
||||
|
||||
export function cardinalToDegrees(label: string): number | null {
|
||||
const upper = label.trim().toUpperCase()
|
||||
const index = CARDINAL_DIRECTIONS.indexOf(upper as CardinalDirection)
|
||||
if (index < 0) return null
|
||||
return (index * 22.5) % 360
|
||||
}
|
||||
|
||||
export function degreesToCardinal(degrees: number): CardinalDirection {
|
||||
const normalized = ((degrees % 360) + 360) % 360
|
||||
const index = Math.round(normalized / 22.5) % 16
|
||||
return CARDINAL_DIRECTIONS[index]
|
||||
}
|
||||
|
||||
export function snapDegrees(degrees: number, step: CourseStep): number {
|
||||
const normalized = ((degrees % 360) + 360) % 360
|
||||
const snapped = Math.round(normalized / step) * step
|
||||
return snapped >= 360 ? 0 : snapped
|
||||
}
|
||||
|
||||
/** 0° = north, clockwise (maritime compass). */
|
||||
export function pointerAngleToDegrees(
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
centerX: number,
|
||||
centerY: number
|
||||
): number {
|
||||
const dx = clientX - centerX
|
||||
const dy = centerY - clientY
|
||||
const radians = Math.atan2(dx, dy)
|
||||
let degrees = (radians * 180) / Math.PI
|
||||
if (degrees < 0) degrees += 360
|
||||
return degrees
|
||||
}
|
||||
|
||||
export function parseCourseAngle(value: string): number | null {
|
||||
const trimmed = value.trim().replace(/°/g, '')
|
||||
if (!trimmed) return null
|
||||
|
||||
const cardinalDeg = cardinalToDegrees(trimmed)
|
||||
if (cardinalDeg !== null) return Math.round(cardinalDeg)
|
||||
|
||||
if (!/^\d{1,3}$/.test(trimmed)) return null
|
||||
const degrees = parseInt(trimmed, 10)
|
||||
if (Number.isNaN(degrees)) return null
|
||||
if (degrees === 360) return 0
|
||||
if (degrees < 0 || degrees > 360) return null
|
||||
return degrees
|
||||
}
|
||||
|
||||
export function formatCourseAngle(degrees: number, pad = false): string {
|
||||
const normalized = ((Math.round(degrees) % 360) + 360) % 360
|
||||
const text = String(normalized)
|
||||
return pad ? text.padStart(3, '0') : text
|
||||
}
|
||||
|
||||
export function normalizeCourseAngleString(
|
||||
value: string,
|
||||
options?: { allowEmpty?: boolean }
|
||||
): string {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return options?.allowEmpty ? '' : ''
|
||||
|
||||
if (isCardinalDirection(trimmed)) {
|
||||
return trimmed.toUpperCase()
|
||||
}
|
||||
|
||||
const parsed = parseCourseAngle(trimmed)
|
||||
if (parsed === null) return trimmed
|
||||
return formatCourseAngle(parsed)
|
||||
}
|
||||
|
||||
export function normalizeWindDirectionString(value: string): string {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return ''
|
||||
|
||||
if (isCardinalDirection(trimmed)) {
|
||||
return trimmed.toUpperCase()
|
||||
}
|
||||
|
||||
const parsed = parseCourseAngle(trimmed)
|
||||
if (parsed === null) return trimmed
|
||||
return formatCourseAngle(parsed)
|
||||
}
|
||||
|
||||
export function valueToDialDegrees(value: string, allowCardinal = false): number {
|
||||
const parsed = parseCourseAngle(value)
|
||||
if (parsed !== null) return parsed
|
||||
if (allowCardinal && isCardinalDirection(value)) {
|
||||
return cardinalToDegrees(value) ?? 0
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
export type CourseOutputMode = 'degrees' | 'cardinal'
|
||||
|
||||
export function resolveCourseOutputMode(
|
||||
value: string,
|
||||
displayMode: 'degrees' | 'cardinal' | 'auto',
|
||||
allowCardinal: boolean
|
||||
): CourseOutputMode {
|
||||
if (!allowCardinal || displayMode === 'degrees') return 'degrees'
|
||||
if (displayMode === 'cardinal') return 'cardinal'
|
||||
return isCardinalDirection(value) ? 'cardinal' : 'degrees'
|
||||
}
|
||||
|
||||
export function dialDegreesToStorageValue(
|
||||
degrees: number,
|
||||
mode: CourseOutputMode,
|
||||
step: CourseStep
|
||||
): string {
|
||||
const snapped = snapDegrees(degrees, step)
|
||||
if (mode === 'cardinal') return degreesToCardinal(snapped)
|
||||
return formatCourseAngle(snapped)
|
||||
}
|
||||
|
||||
export function formatCourseDisplay(
|
||||
value: string,
|
||||
allowCardinal: boolean
|
||||
): string {
|
||||
if (!value.trim()) return '—'
|
||||
if (allowCardinal && isCardinalDirection(value)) return value.toUpperCase()
|
||||
const parsed = parseCourseAngle(value)
|
||||
if (parsed === null) return value
|
||||
return `${formatCourseAngle(parsed, true)}°`
|
||||
}
|
||||
|
||||
const STEP_STORAGE_KEY = 'kaptein-course-dial-step'
|
||||
|
||||
export function loadCourseDialStep(): CourseStep {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STEP_STORAGE_KEY)
|
||||
if (raw === '5') return 5
|
||||
if (raw === '10') return 10
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
export function saveCourseDialStep(step: CourseStep): void {
|
||||
try {
|
||||
sessionStorage.setItem(STEP_STORAGE_KEY, String(step))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { crc32Hex, nmeaFileCrc32, normalizeNmeaTextForCrc } from './crc32.js'
|
||||
|
||||
describe('crc32', () => {
|
||||
it('hashes known test vectors', () => {
|
||||
expect(crc32Hex('')).toBe('00000000')
|
||||
expect(crc32Hex('123456789')).toBe('CBF43926')
|
||||
})
|
||||
|
||||
it('normalizes line endings before hashing NMEA content', () => {
|
||||
const a = nmeaFileCrc32('$GPRMC,123519,A\r\n$GPGGA,123519\r\n')
|
||||
const b = nmeaFileCrc32('$GPRMC,123519,A\n$GPGGA,123519\n')
|
||||
expect(a).toBe(b)
|
||||
expect(normalizeNmeaTextForCrc('a\r\nb\r')).toBe('a\nb')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
/** Normalize NMEA text so identical content hashes the same across platforms. */
|
||||
export function normalizeNmeaTextForCrc(text: string): string {
|
||||
return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trimEnd()
|
||||
}
|
||||
|
||||
const CRC32_TABLE = (() => {
|
||||
const table = new Uint32Array(256)
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let c = i
|
||||
for (let k = 0; k < 8; k++) {
|
||||
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
|
||||
}
|
||||
table[i] = c >>> 0
|
||||
}
|
||||
return table
|
||||
})()
|
||||
|
||||
/** CRC-32 (IEEE / Ethernet polynomial), uppercase 8-char hex. */
|
||||
export function crc32Hex(text: string): string {
|
||||
const bytes = new TextEncoder().encode(text)
|
||||
let crc = 0xffffffff
|
||||
for (const byte of bytes) {
|
||||
crc = CRC32_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8)
|
||||
}
|
||||
return ((crc ^ 0xffffffff) >>> 0).toString(16).toUpperCase().padStart(8, '0')
|
||||
}
|
||||
|
||||
export function nmeaFileCrc32(text: string): string {
|
||||
return crc32Hex(normalizeNmeaTextForCrc(text))
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user