Compare commits
169 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| dee2f7b95b | |||
| 4eaf5d7f30 | |||
| 257bca14d1 | |||
| 917fb92d85 | |||
| b48b31580d | |||
| 7f0223c636 | |||
| 68af8c6361 | |||
| ad7e036ab7 | |||
| 12c02f6392 | |||
| 3698c6fbca | |||
| d4538ec06e | |||
| 86cb4d92ec | |||
| b72b20b66c | |||
| 6ad75ff947 | |||
| 75eba362d6 | |||
| afc5a1e200 | |||
| 79a54fdfc2 | |||
| e73c078463 | |||
| 2eb6551200 | |||
| 9baaccf239 | |||
| df53420f3b | |||
| 5271ed90c1 | |||
| a8ba998444 | |||
| 67d169080e | |||
| c67c1425df | |||
| d231a7fb40 | |||
| 4acb9b1290 | |||
| 4484724d38 | |||
| 5ea5111ec3 | |||
| 7ab0ec6061 | |||
| 258fee31ab | |||
| 2e83f1c6bb | |||
| fcb76d1305 | |||
| 7d96bbcfd8 | |||
| a586fcbfba | |||
| 0ed9ac6941 | |||
| b4fff04ee1 | |||
| 7e01106801 | |||
| caf6e395cd | |||
| a67575f4d2 | |||
| c2d620025e | |||
| 1524321afd | |||
| ab8a188fa0 | |||
| bb98af040e | |||
| 333c36db21 | |||
| 3bd1970c59 | |||
| 75c1369c75 | |||
| 9ce1e384b7 | |||
| 3eee42a30c | |||
| 90ffff0da6 | |||
| 5c815caf8a | |||
| c3836eb07d | |||
| caf7d81ac9 | |||
| 8bcfb97e98 | |||
| b9ccb0dfb6 | |||
| d98e2e8dc0 | |||
| f5f12f50f5 | |||
| 1437b75c2f | |||
| 7d75e74679 | |||
| 0276d8445e | |||
| dea33e3f00 | |||
| 4f3f530f1f | |||
| 858d5d1d25 | |||
| c914156d70 | |||
| 8bf89ed898 | |||
| adf8ee9929 | |||
| 1055a12dad | |||
| f1f90da069 | |||
| 4541c81d3b | |||
| 03bb55f9a1 | |||
| 69d5203305 | |||
| e8f9381c5f | |||
| 442ddccceb | |||
| f47413999c | |||
| f23f0db70b | |||
| ece0abccbf | |||
| 92e9020212 | |||
| 2428313a22 | |||
| 0e61bc5dad | |||
| 585ef788df | |||
| 9aabb2729d | |||
| ebe4199b8b | |||
| 10f01f1ffc | |||
| 29765d172e | |||
| 5f9e83dbdd | |||
| aa2b35ddac | |||
| b5bc80594c | |||
| b88ce17e1d | |||
| 3849b5a2f0 | |||
| 1225601d7a | |||
| 180e5727df | |||
| 94b13c8d60 | |||
| 69dddf7838 | |||
| 53eee9a3ad | |||
| ebe21c5a6f | |||
| 61f04902cb | |||
| 166eeaf000 | |||
| c1418b5981 | |||
| 181459c7e8 | |||
| ebeb05e865 | |||
| 64c0d8cd47 | |||
| e2e65e80ef | |||
| 4d3ba58971 | |||
| c5090aa59e | |||
| fa8a381739 | |||
| aeb304baf6 | |||
| ea3985f425 | |||
| 4b8e04262d | |||
| e24148923f | |||
| b317be5ae1 | |||
| 481724bcb6 | |||
| 96ebb8357d | |||
| 415a7a4e4e | |||
| cb4f1b5989 | |||
| b37f935e87 | |||
| 213001b139 | |||
| 95cf42d1f6 | |||
| 95cfc3872b | |||
| bb85e799cf | |||
| 32f1fa1d79 | |||
| f70e31dfb6 | |||
| 4f1702ba2a | |||
| a4c7fcfc6f | |||
| e3aeae1966 | |||
| 760b369b39 | |||
| 166afac18a | |||
| cd2467d1fd | |||
| 9502719816 | |||
| 2926d743fb | |||
| f04a91d640 | |||
| 571c93cfe1 | |||
| 7d5d9de3c1 | |||
| ab7670c3fc | |||
| 41fb106153 | |||
| 268500237d | |||
| 66a32e0367 | |||
| 819d84eaee | |||
| 51ffc33f32 | |||
| 4c3f93602c | |||
| 181cbe4895 | |||
| 0da855381d | |||
| 646d316a36 | |||
| 593d1aea20 |
@@ -0,0 +1,182 @@
|
||||
---
|
||||
name: merge
|
||||
description: >-
|
||||
Merge Git branches safely — fetch latest, merge or rebase onto master, resolve
|
||||
conflicts intelligently, and verify the result. Use when the user asks to merge
|
||||
branches, sync with master, resolve merge conflicts, or bring a feature branch
|
||||
up to date.
|
||||
---
|
||||
|
||||
# Git Merge
|
||||
|
||||
Führe Branch-Merges sicher und nachvollziehbar aus. Für PR-Review, CI und
|
||||
Comment-Triage siehe den **babysit**-Skill — dieser Skill deckt die Git-Merge-
|
||||
Operation selbst ab.
|
||||
|
||||
## Projekt-Kontext
|
||||
|
||||
- **Basis-Branch:** `master` (nicht `main`)
|
||||
- **Monorepo:** `client/` (React PWA) und `server/` (Express API) — Konflikte
|
||||
können in beiden liegen
|
||||
|
||||
## Sicherheitsregeln (immer einhalten)
|
||||
|
||||
- **Niemals** `git config` ändern
|
||||
- **Niemals** `--no-verify`, `--no-gpg-sign` o.ä. ohne explizite Anfrage
|
||||
- **Niemals** `push --force` auf `master` — bei Bedarf warnen und abbrechen
|
||||
- **Niemals** destruktive Befehle (`reset --hard`, `clean -fd`) ohne explizite Anfrage
|
||||
- **Niemals** interaktive Git-Befehle (`-i`-Flags) — nicht unterstützt
|
||||
- **Kein Commit** ohne explizite Anfrage des Users
|
||||
- **Kein Push** ohne explizite Anfrage des Users
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Ausgangslage klären
|
||||
|
||||
Parallel ausführen:
|
||||
|
||||
```bash
|
||||
git status
|
||||
git branch -vv
|
||||
git log --oneline -5
|
||||
```
|
||||
|
||||
Ermittle:
|
||||
|
||||
- Aktueller Branch
|
||||
- Ziel-Branch (Standard: `master`)
|
||||
- Ob uncommittete Änderungen vorliegen
|
||||
- Ob der Branch einen Remote-Tracking-Branch hat
|
||||
|
||||
**Bei uncommitteten Änderungen:** Stashen (`git stash push -m "pre-merge"`) nur
|
||||
mit Zustimmung oder wenn der User es verlangt hat. Sonst stoppen und melden.
|
||||
|
||||
### 2. Merge-Strategie wählen
|
||||
|
||||
| Situation | Empfehlung |
|
||||
|-----------|------------|
|
||||
| Feature-Branch aktuell halten | `git merge origin/master` (Merge-Commit) |
|
||||
| Linearer Verlauf gewünscht | `git rebase origin/master` (nur wenn User Rebase verlangt) |
|
||||
| Zwei Feature-Branches zusammenführen | `git merge <branch>` auf Ziel-Branch |
|
||||
|
||||
**Standard:** Merge (nicht Rebase), es sei denn der User verlangt Rebase.
|
||||
|
||||
### 3. Remote aktualisieren
|
||||
|
||||
```bash
|
||||
git fetch origin
|
||||
```
|
||||
|
||||
Vor dem Merge prüfen, wie weit der Branch hinter `origin/master` liegt:
|
||||
|
||||
```bash
|
||||
git log --oneline HEAD..origin/master
|
||||
git log --oneline origin/master..HEAD
|
||||
```
|
||||
|
||||
### 4. Merge ausführen
|
||||
|
||||
**Feature-Branch mit master synchronisieren** (häuigster Fall):
|
||||
|
||||
```bash
|
||||
git checkout <feature-branch>
|
||||
git merge origin/master
|
||||
```
|
||||
|
||||
**Branch in master mergen** (nur wenn User das ausdrücklich will — normalerweise
|
||||
passiert das via PR):
|
||||
|
||||
```bash
|
||||
git checkout master
|
||||
git pull origin master
|
||||
git merge <feature-branch>
|
||||
```
|
||||
|
||||
Merge-Commit-Nachricht kurz und sachlich halten, z.B.:
|
||||
`Merge branch 'master' into feature/push-notifications-owner`
|
||||
|
||||
### 5. Konflikte lösen
|
||||
|
||||
Konfliktdateien finden:
|
||||
|
||||
```bash
|
||||
git diff --name-only --diff-filter=U
|
||||
```
|
||||
|
||||
**Pro Konfliktdatei:**
|
||||
|
||||
1. Datei lesen und beide Seiten verstehen (HEAD = eigener Branch, incoming = gemergter Branch)
|
||||
2. Intent beider Änderungen erhalten — nicht blind eine Seite wählen
|
||||
3. Konfliktmarker entfernen (`<<<<<<<`, `=======`, `>>>>>>>`)
|
||||
4. Bei widersprüchlicher Intent: Merge abbrechen und User fragen
|
||||
|
||||
```bash
|
||||
git merge --abort # oder: git rebase --abort
|
||||
```
|
||||
|
||||
**Typische Konflikt-Muster in diesem Projekt:**
|
||||
|
||||
| Bereich | Hinweis |
|
||||
|---------|---------|
|
||||
| `package-lock.json` | Nach manueller Lösung `npm install` im betroffenen Paket (`client/` oder `server/`) ausführen |
|
||||
| i18n (`client/src/i18n/`) | Beide Sprachkeys (DE + EN) behalten, keine Keys verlieren |
|
||||
| Prisma/Schema | Migrationen beider Seiten zusammenführen, nicht überschreiben |
|
||||
| Verschlüsselung/Auth | Vorsichtig — keine Sicherheitslogik stillschweigend vereinfachen |
|
||||
|
||||
Nach jeder gelösten Datei:
|
||||
|
||||
```bash
|
||||
git add <file>
|
||||
```
|
||||
|
||||
Merge abschließen (nur wenn User Commit verlangt hat):
|
||||
|
||||
```bash
|
||||
git commit -m "$(cat <<'EOF'
|
||||
Merge branch 'master' into <feature-branch>
|
||||
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
### 6. Verifizieren
|
||||
|
||||
Nach erfolgreichem Merge:
|
||||
|
||||
```bash
|
||||
git status
|
||||
git log --oneline -5
|
||||
```
|
||||
|
||||
Relevante Checks je nach betroffenen Bereichen:
|
||||
|
||||
```bash
|
||||
# Client
|
||||
cd client && npm run build
|
||||
|
||||
# Server
|
||||
cd server && npm run build
|
||||
```
|
||||
|
||||
Bei Lockfile-Konflikten oder Dependency-Änderungen: Build in beiden Paketen prüfen.
|
||||
|
||||
### 7. Abschluss
|
||||
|
||||
- Ergebnis dem User mitteilen: welche Branches, wie viele Konflikte, was gelöst wurde
|
||||
- Bei `git stash`: erinnern, Stash wieder anzuwenden (`git stash pop`)
|
||||
- Push nur auf explizite Anfrage: `git push origin <branch>`
|
||||
|
||||
## Wann abbrechen und fragen
|
||||
|
||||
- Widersprüchliche fachliche Intent (z.B. beide Seiten ändern dieselbe Logik unterschiedlich)
|
||||
- Konflikte in Krypto-, Auth- oder Sync-Kernlogik ohne klares „richtig“
|
||||
- Merge würde `.env`, Credentials oder Secrets einschließen
|
||||
- User wollte nur Status prüfen, nicht tatsächlich mergen
|
||||
|
||||
## Abgrenzung zu anderen Skills
|
||||
|
||||
| Skill | Wann |
|
||||
|-------|------|
|
||||
| **merge** (dieser) | Git merge/rebase, Konflikte, Branch sync |
|
||||
| **babysit** | PR merge-ready: Comments, CI, PR-Konflikte im PR-Kontext |
|
||||
| **creating-pull-requests** | PR erstellen und pushen |
|
||||
+20
-1
@@ -4,4 +4,23 @@ OpenWeatherMapAPIKey=<owm_api_key>
|
||||
# For local dev: localhost and http://localhost
|
||||
# For production: e.g. kapteins-daagbok.eu and https://kapteins-daagbok.eu
|
||||
RP_ID=localhost
|
||||
ORIGIN=http://localhost
|
||||
# Must match the frontend URL (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
|
||||
|
||||
# API session signing (min. 32 chars; required in production)
|
||||
# Generate: openssl rand -base64 48
|
||||
SESSION_SECRET=
|
||||
|
||||
# Web Push (VAPID) — generate with: npx web-push generate-vapid-keys
|
||||
# Public key may also be set on the client as VITE_VAPID_PUBLIC_KEY
|
||||
VAPID_PUBLIC_KEY=
|
||||
VAPID_PRIVATE_KEY=
|
||||
VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu
|
||||
|
||||
# Feedback via Ntfy (https://ntfy.sh or self-hosted)
|
||||
# NTFY_TOPIC: topic name only (not the full URL)
|
||||
NTFY_SERVER=https://ntfy.sh
|
||||
NTFY_TOPIC=kapteins-daagbok-feedback
|
||||
NTFY_TOKEN=tk_example_ntfy_access_token
|
||||
|
||||
@@ -168,7 +168,7 @@ Beim Laden eines Eintrags: `computedHash !== sig.entryHash` → UI-Warnung.
|
||||
|
||||
Neuer Router: `server/src/routes/sign.ts` → Mount unter `/api/sign`
|
||||
|
||||
Auth wie bestehend: Header `X-User-Id` (siehe `sync.ts`).
|
||||
Auth wie bestehend: HttpOnly-Session-Cookie `daagbok_session` nach WebAuthn (`server/src/middleware/auth.ts`, Client `apiFetch` mit `credentials: 'include'`).
|
||||
|
||||
### 4.1 `POST /api/sign/options`
|
||||
|
||||
@@ -472,7 +472,7 @@ test('isSignatureValidForEntry')
|
||||
| WebAuthn Login | `client/src/services/auth.ts`, `server/src/routes/auth.ts` |
|
||||
| Collaborators | `server/src/routes/collaboration.ts`, `SettingsForm.tsx` |
|
||||
| E2E-Einträge | `EntryPayload` in `server/prisma/schema.prisma` |
|
||||
| Auth-Header | `X-User-Id` in `server/src/routes/sync.ts` |
|
||||
| API-Auth | Session-Cookie via `requireUser` in `server/src/middleware/auth.ts` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
# Kapteins Daagbok
|
||||
|
||||
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)
|
||||
|
||||
## Überblick
|
||||
|
||||
Kapteins Daagbok richtet sich an private Skipper und Yachtbesitzer, die ihr Bordlogbuch digital führen möchten. Die App speichert Schiffsdaten, Crew-Profile und Reisetage (Törns) in einem Format, das an übliche nautische Logbuch-Vorlagen angelehnt ist.
|
||||
|
||||
Alle sensiblen Inhalte werden **clientseitig verschlüsselt** (Web Crypto API). Der Server sieht nur ciphertext — eine Zero-Knowledge-Architektur. Daten liegen zusätzlich lokal in IndexedDB (Dexie.js) und synchronisieren im Hintergrund, sodass die App **auch offline** auf See nutzbar ist.
|
||||
|
||||
## Funktionen
|
||||
|
||||
- **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
|
||||
- **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)
|
||||
- **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)
|
||||
- **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
|
||||
- **PWA** — installierbar auf iOS/Android, Offline-Modus, Update-Hinweise
|
||||
- **Mehrsprachig** — Deutsch und Englisch
|
||||
- **Demo-Logbuch & Onboarding-Tour** für neue Nutzer
|
||||
|
||||
## Architektur
|
||||
|
||||
```
|
||||
┌─────────────────┐ HTTPS/API ┌─────────────────┐
|
||||
│ React PWA │ ◄──────────────────► │ Express API │
|
||||
│ Vite + Dexie │ (nur ciphertext) │ Prisma + PG │
|
||||
│ IndexedDB │ │ PostgreSQL │
|
||||
└─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
| Schicht | Technologie |
|
||||
|---------|-------------|
|
||||
| Frontend | React 19, TypeScript, Vite, vite-plugin-pwa |
|
||||
| Lokaler Speicher | Dexie.js (IndexedDB), Hintergrund-Sync |
|
||||
| Backend | Node.js, Express, Prisma |
|
||||
| Datenbank | PostgreSQL 16 |
|
||||
| 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`) |
|
||||
|
||||
### Rollen & Zugriff
|
||||
|
||||
| Rolle | Bedeutung |
|
||||
|-------|-----------|
|
||||
| **Owner** | Logbuch angelegt; voller Zugriff, Einladungen, Backup, Löschen; optional Push bei Crew-Änderungen |
|
||||
| **Collaborator (WRITE)** | Per Einladung; Einträge bearbeiten und als Crew signieren |
|
||||
| **Collaborator (READ)** | Nur Lesen (z. B. öffentlicher Share-Link) |
|
||||
|
||||
Skipper- und Crew-Profile im Logbuch sind **Inhaltsdaten** (verschlüsselt), nicht an den Account gebunden. Ein Account kann gleichzeitig Owner eines eigenen und Collaborator in fremden Logbüchern sein.
|
||||
|
||||
### Authentifizierung & Session
|
||||
|
||||
| Schicht | Verhalten |
|
||||
|---------|-----------|
|
||||
| **Login** | WebAuthn (`/api/auth/login-verify`) — danach HttpOnly-Cookie, 7 Tage gültig |
|
||||
| **API-Aufrufe** | Cookie `credentials: 'include'` (Client: `apiFetch`) — kein `X-User-Id` |
|
||||
| **Master-Key** | Nur im RAM; nach Reload Entsperren per Passkey oder lokalem PIN |
|
||||
| **Step-up** | Konto löschen, PRF-Enrollment: frische Passkey-Bestätigung (`/api/auth/reauth-*`) |
|
||||
| **Sync WRITE** | Server lehnt Schreib-Sync für Collaborator mit `READ` ab |
|
||||
|
||||
Öffentliche Routen (ohne Session): Registrierung/Login-Optionen, Einladungsdetails, Read-only-Share (`share-pull`), Health-Check, VAPID-Public-Key.
|
||||
|
||||
## Backup & Wiederherstellung
|
||||
|
||||
Nur der **Logbuch-Eigner** kann unter **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
|
||||
3. **Wiederherstellen** in einem beliebigen Account (nach Registrierung/Login): Datei + Passphrase
|
||||
|
||||
Vor dem Löschen eines Logbuchs weist die App auf diese Funktion hin. Crew-Einladungen und Passkey-Signaturen werden nicht mitübertragen — Inhalte bleiben lesbar, Signaturen auf neuem Account ggf. nicht mehr verifizierbar.
|
||||
|
||||
## 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).
|
||||
|
||||
| Aspekt | Verhalten |
|
||||
|--------|-----------|
|
||||
| Auslöser | Erfolgreicher Sync-Push durch Collaborator (`create`/`update`) |
|
||||
| Aggregation | Mehrere Änderungen in einem Sync → eine Benachrichtigung pro Logbuch |
|
||||
| Drosselung | Max. eine Push-Nachricht pro Logbuch alle 3 Minuten |
|
||||
| Klick | Öffnet die App auf dem betroffenen Logbuch |
|
||||
|
||||
**Voraussetzungen:**
|
||||
|
||||
- HTTPS (Produktion)
|
||||
- VAPID-Schlüssel auf dem Server (`VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `VAPID_SUBJECT`)
|
||||
- Browser-Berechtigung „Benachrichtigungen“; auf **iOS** installierte PWA ab iOS 16.4+
|
||||
|
||||
Schlüssel erzeugen: `npx web-push generate-vapid-keys` (im `server/`-Verzeichnis oder global).
|
||||
|
||||
Ausführlicher Implementierungs- und Testplan: [docs/push-notifications-plan.md](docs/push-notifications-plan.md).
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
```
|
||||
kapteins-daagbok/
|
||||
├── client/ # React-PWA (Frontend)
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # UI-Komponenten
|
||||
│ │ ├── 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)
|
||||
│ └── prisma/ # Datenbankschema
|
||||
├── docs/ # Projektdokumentation
|
||||
├── scripts/ # Dev- und Deploy-Skripte
|
||||
├── docker-compose.yml # Produktions-Stack (DB + Backend + Frontend)
|
||||
└── VERSION # App-Version (Build & Footer)
|
||||
```
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- **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: VAPID-Schlüssel für Web Push (siehe Abschnitt Push-Benachrichtigungen)
|
||||
|
||||
## Lokale Entwicklung
|
||||
|
||||
### 1. Abhängigkeiten installieren
|
||||
|
||||
```bash
|
||||
cd server && npm ci && cd ..
|
||||
cd client && npm ci && cd ..
|
||||
```
|
||||
|
||||
### 2. Umgebungsvariablen
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Kopiere `.env.example` nach `.env` und passe mindestens an:
|
||||
|
||||
| Variable | Dev (Vite) | Produktion |
|
||||
|----------|------------|------------|
|
||||
| `RP_ID` | `localhost` | `kapteins-daagbok.eu` |
|
||||
| `ORIGIN` | `http://localhost:5173` | `https://kapteins-daagbok.eu` |
|
||||
| `SESSION_SECRET` | empfohlen (≥ 32 Zeichen) | **Pflicht** |
|
||||
|
||||
`ORIGIN` muss **exakt** der Frontend-URL entsprechen (CORS + Session-Cookie). Das Backend lädt `.env` aus dem Projektroot und optional `server/.env`.
|
||||
|
||||
```
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/daagbox?schema=public"
|
||||
OpenWeatherMapAPIKey= # Fallback für Wetter-Abruf, wenn Nutzer keinen eigenen Key hat
|
||||
RP_ID=localhost
|
||||
ORIGIN=http://localhost:5173
|
||||
SESSION_SECRET= # openssl rand -base64 48 (in Prod Pflicht)
|
||||
# Optional — Web Push (npx web-push generate-vapid-keys)
|
||||
VAPID_PUBLIC_KEY=
|
||||
VAPID_PRIVATE_KEY=
|
||||
VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu
|
||||
```
|
||||
|
||||
`./scripts/start-dev.sh` prüft `ORIGIN` und `SESSION_SECRET` beim Start und gibt Hinweise aus.
|
||||
|
||||
### 3. Datenbank & Schema
|
||||
|
||||
Das Dev-Skript startet PostgreSQL in Docker (`postgres-daagbox`). Schema anwenden:
|
||||
|
||||
```bash
|
||||
cd server && npx prisma db push && cd ..
|
||||
```
|
||||
|
||||
### 4. Dev-Server starten
|
||||
|
||||
```bash
|
||||
./scripts/start-dev.sh
|
||||
```
|
||||
|
||||
| Dienst | URL |
|
||||
|--------|-----|
|
||||
| Frontend (Vite) | http://localhost:5173 |
|
||||
| Backend API | http://localhost:5000 |
|
||||
| Health Check | http://localhost:5000/api/health |
|
||||
|
||||
## Docker (produktionsnah)
|
||||
|
||||
Gesamten Stack lokal bauen und starten:
|
||||
|
||||
```bash
|
||||
./scripts/start-dev-docker.sh
|
||||
```
|
||||
|
||||
Frontend: http://localhost · API: http://localhost/api/health
|
||||
|
||||
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`).
|
||||
|
||||
## Deployment
|
||||
|
||||
Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen):
|
||||
|
||||
```bash
|
||||
./scripts/update-prod.sh
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Dokumentation
|
||||
|
||||
| Dokument | Inhalt |
|
||||
|----------|--------|
|
||||
| [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/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) |
|
||||
|
||||
## Analytics
|
||||
|
||||
Die App nutzt [Plausible Analytics](https://plausible.io/) (self-hosted) für anonyme Nutzungsmetriken — ohne Cookies und ohne personenbezogene Daten in Event-Properties. Details und Goal-Namen: [docs/plausible-events.md](docs/plausible-events.md).
|
||||
|
||||
## Version
|
||||
|
||||
Aktuelle Version: siehe [VERSION](VERSION) (wird im App-Footer und beim Docker-Build eingebunden).
|
||||
|
||||
---
|
||||
|
||||
© 2026 KnorrLabs/Markus F.J. Busche · [kapteins-daagbok.eu](https://kapteins-daagbok.eu)
|
||||
+27
-3
@@ -1,18 +1,42 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Kostenloses, werbefreies digitales Yacht-Logbuch mit End-to-End-Verschlüsselung und Passkey-Anmeldung. Reisetage, GPS-Tracks, Crew und Schiffsdaten sicher dokumentieren – auch offline als PWA." />
|
||||
<meta name="keywords" content="Yacht-Logbuch, Schiffstagebuch, Bordlogbuch, Segeln, Passkey, E2E-Verschlüsselung, GPS-Track, maritimes Logbuch, kostenlos, werbefrei, gratis, ohne Werbung, yacht logbook, sailing log, ad-free" />
|
||||
<meta name="author" content="Markus F.J. Busche" />
|
||||
<meta name="robots" content="index, follow" />
|
||||
<meta name="application-name" content="Kapteins Daagbok" />
|
||||
<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="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" />
|
||||
<link rel="apple-touch-icon" href="/logo.png" />
|
||||
<title>Kapteins Daagbok</title>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Kapteins Daagbok" />
|
||||
<meta property="og:title" content="Kapteins Daagbok – Kostenloses digitales Yacht-Logbuch" />
|
||||
<meta property="og:description" content="Kostenlos und werbefrei: sicheres, E2E-verschlüsseltes Logbuch für Skipper. Reisetage, GPS-Tracks, Crew- und Schiffsdaten – Passkey-Anmeldung und Offline-PWA." />
|
||||
<meta property="og:url" content="https://kapteins-daagbok.eu/" />
|
||||
<meta property="og:image" content="https://kapteins-daagbok.eu/logo.png" />
|
||||
<meta property="og:image:alt" content="Kapteins Daagbok Logo" />
|
||||
<meta property="og:locale" content="de_DE" />
|
||||
<meta property="og:locale:alternate" content="en_US" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:title" content="Kapteins Daagbok – Kostenloses digitales Yacht-Logbuch" />
|
||||
<meta name="twitter:description" content="Kostenlos und werbefrei: sicheres, E2E-verschlüsseltes Logbuch für Skipper. Reisetage, GPS-Tracks, Crew- und Schiffsdaten – Passkey-Anmeldung und Offline-PWA." />
|
||||
<meta name="twitter:image" content="https://kapteins-daagbok.eu/logo.png" />
|
||||
<meta name="twitter:image:alt" content="Kapteins Daagbok Logo" />
|
||||
<script defer data-domain="kapteins-daagbok.eu" src="https://plausible.elpatron.me/js/script.tagged-events.js"></script>
|
||||
<title>Kapteins Daagbok – Kostenloses digitales Yacht-Logbuch (werbefrei)</title>
|
||||
</head>
|
||||
<body>
|
||||
<body style="margin:0;background:#0b0c10;color:#e2e8f0">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
Generated
+1479
-663
File diff suppressed because it is too large
Load Diff
+14
-4
@@ -7,7 +7,10 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"test": "vitest run",
|
||||
"preview": "vite preview",
|
||||
"generate:flyer": "node ../scripts/generate-beta-flyer.mjs",
|
||||
"generate:flyer:setup": "playwright install chromium"
|
||||
},
|
||||
"dependencies": {
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
@@ -29,14 +32,21 @@
|
||||
"@types/node": "^24.12.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"eslint": "^10.3.0",
|
||||
"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": "^8.0.12",
|
||||
"vite-plugin-pwa": "^1.3.0"
|
||||
"vite": "^6.3.5",
|
||||
"vite-plugin-pwa": "^1.0.1",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
+1848
-13
File diff suppressed because it is too large
Load Diff
+429
-57
@@ -1,16 +1,28 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import './App.css'
|
||||
import { DialogProvider } from './components/ModalDialog.tsx'
|
||||
import AuthOnboarding from './components/AuthOnboarding.tsx'
|
||||
import UserProfilePage from './components/UserProfilePage.tsx'
|
||||
import LogbookDashboard from './components/LogbookDashboard.tsx'
|
||||
import VesselForm from './components/VesselForm.tsx'
|
||||
import CrewForm from './components/CrewForm.tsx'
|
||||
// Compass Deviation Table — für Freizeit-Skipper vorerst deaktiviert (Komponente bleibt erhalten)
|
||||
// import DeviationForm from './components/DeviationForm.tsx'
|
||||
import LogEntriesList from './components/LogEntriesList.tsx'
|
||||
import StatsDashboard from './components/StatsDashboard.tsx'
|
||||
import SettingsForm from './components/SettingsForm.tsx'
|
||||
import InvitationAcceptance from './components/InvitationAcceptance.tsx'
|
||||
import { getActiveMasterKey, logoutUser } from './services/auth.js'
|
||||
import AppTourOverlay from './components/AppTourOverlay.tsx'
|
||||
import { AppTourProvider, useAppTour, type AppTab } from './context/AppTourContext.tsx'
|
||||
import { UnsavedChangesProvider, useUnsavedChangesContext } from './context/UnsavedChangesContext.tsx'
|
||||
import {
|
||||
logoutUser,
|
||||
checkServerSession,
|
||||
hasUnlockedLocalSession,
|
||||
persistSessionUserId
|
||||
} from './services/auth.js'
|
||||
import AppErrorBoundary from './components/AppErrorBoundary.tsx'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from './services/analytics.js'
|
||||
import {
|
||||
applyAppearanceToDocument,
|
||||
resolveAppTheme,
|
||||
@@ -19,34 +31,101 @@ import {
|
||||
} from './services/appearance.js'
|
||||
import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks, subscribeToSyncState } from './services/sync.js'
|
||||
import ReadOnlyViewer from './components/ReadOnlyViewer.tsx'
|
||||
import DemoViewer from './components/DemoViewer.tsx'
|
||||
import PwaInstallPrompt from './components/PwaInstallPrompt.tsx'
|
||||
import PwaUpdatePrompt from './components/PwaUpdatePrompt.tsx'
|
||||
import AppFooter from './components/AppFooter.tsx'
|
||||
import LogbookRoleBadge from './components/LogbookRoleBadge.tsx'
|
||||
import BetaBadge from './components/BetaBadge.tsx'
|
||||
import { db } from './services/db.js'
|
||||
import { getLogbookAccess } from './services/logbookAccess.js'
|
||||
import type { LogbookAccessRole } from './services/logbook.js'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff } from 'lucide-react'
|
||||
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 { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
getStoredDemoFirstEntryId,
|
||||
seedDemoLogbookIfNeeded
|
||||
} from './services/demoLogbook.js'
|
||||
import { fetchLogbooks, parseCollaborationRole } from './services/logbook.js'
|
||||
import { ensurePushSubscriptionIfEnabled } from './services/pushNotifications.js'
|
||||
|
||||
const PENDING_PUSH_LOGBOOK_KEY = 'pending_push_logbook_id'
|
||||
|
||||
function App() {
|
||||
const { t } = useTranslation()
|
||||
const { t, i18n } = useTranslation()
|
||||
const { confirmLeave } = useUnsavedChangesContext()
|
||||
const { registerNavigation, requestStartAfterLogin, isActive, currentStepId } = useAppTour()
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||
const [activeLogbookId, setActiveLogbookId] = useState<string | null>(null)
|
||||
const [activeLogbookTitle, setActiveLogbookTitle] = useState<string | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<'vessel' | 'crew' | 'logs' | 'settings'>('logs')
|
||||
const [activeTab, setActiveTab] = useState<AppTab>('logs')
|
||||
const [tourSelectedEntryId, setTourSelectedEntryId] = useState<string | null>(null)
|
||||
const [tourFeedbackOpen, setTourFeedbackOpen] = useState(false)
|
||||
const [demoHighlightEntryId, setDemoHighlightEntryId] = useState<string | null>(null)
|
||||
const [online, setOnline] = useState(navigator.onLine)
|
||||
const [isSyncing, setIsSyncing] = useState(false)
|
||||
const [isAcceptingInvite, setIsAcceptingInvite] = useState(false)
|
||||
const [showUserProfile, setShowUserProfile] = useState(false)
|
||||
|
||||
// Viewer mode for read-only shared links
|
||||
const [isViewerMode, setIsViewerMode] = useState(false)
|
||||
const [shareToken, setShareToken] = useState('')
|
||||
const [shareKey, setShareKey] = useState('')
|
||||
|
||||
// Public demo mode (no account required)
|
||||
const [isDemoMode, setIsDemoMode] = useState(() => window.location.pathname === '/demo')
|
||||
|
||||
const syncQueueCount = useLiveQuery(
|
||||
() => activeLogbookId ? db.syncQueue.where({ logbookId: activeLogbookId }).count() : db.syncQueue.count(),
|
||||
[activeLogbookId]
|
||||
)
|
||||
|
||||
const activeLogbookRecord = useLiveQuery(
|
||||
() => (activeLogbookId ? db.logbooks.get(activeLogbookId) : undefined),
|
||||
[activeLogbookId]
|
||||
)
|
||||
|
||||
const [activeAccessRole, setActiveAccessRole] = useState<LogbookAccessRole | null>('OWNER')
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeLogbookId) {
|
||||
setActiveAccessRole('OWNER')
|
||||
return
|
||||
}
|
||||
|
||||
if (!activeLogbookRecord) {
|
||||
setActiveAccessRole(null)
|
||||
return
|
||||
}
|
||||
|
||||
if (activeLogbookRecord.isShared !== 1) {
|
||||
setActiveAccessRole('OWNER')
|
||||
return
|
||||
}
|
||||
|
||||
const cachedRole = activeLogbookRecord.collaborationRole
|
||||
setActiveAccessRole(
|
||||
cachedRole ? parseCollaborationRole(cachedRole, `logbook ${activeLogbookId}`) : null
|
||||
)
|
||||
|
||||
let cancelled = false
|
||||
getLogbookAccess(activeLogbookId)
|
||||
.then((access) => {
|
||||
if (cancelled || !access) return
|
||||
setActiveAccessRole(access.role)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('Failed to resolve logbook access role:', err)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [activeLogbookId, activeLogbookRecord])
|
||||
|
||||
useEffect(() => {
|
||||
const syncAppearance = () => {
|
||||
applyAppearanceToDocument(resolveAppTheme(), resolveColorScheme())
|
||||
@@ -91,67 +170,286 @@ function App() {
|
||||
}
|
||||
}, [isAuthenticated])
|
||||
|
||||
useEffect(() => {
|
||||
const syncRouteFromLocation = useCallback(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const hashParams = new URLSearchParams(window.location.hash.substring(1))
|
||||
const path = window.location.pathname
|
||||
|
||||
if (window.location.pathname === '/share' && params.has('token') && hashParams.has('key')) {
|
||||
setShareToken(params.get('token') || '')
|
||||
setShareKey(hashParams.get('key') || '')
|
||||
setIsViewerMode(true)
|
||||
if (path === '/demo') {
|
||||
setIsDemoMode(true)
|
||||
setIsViewerMode(false)
|
||||
setIsAcceptingInvite(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (params.has('token')) {
|
||||
setIsAcceptingInvite(true)
|
||||
setIsDemoMode(false)
|
||||
|
||||
if (path === '/share' && params.has('token') && hashParams.has('key')) {
|
||||
setShareToken(params.get('token') || '')
|
||||
setShareKey(hashParams.get('key') || '')
|
||||
setIsViewerMode(true)
|
||||
setIsAcceptingInvite(false)
|
||||
return
|
||||
}
|
||||
|
||||
const savedUser = localStorage.getItem('active_username')
|
||||
const key = getActiveMasterKey()
|
||||
if (savedUser && key) {
|
||||
setIsAuthenticated(true)
|
||||
const savedLogbookId = localStorage.getItem('active_logbook_id')
|
||||
const savedLogbookTitle = localStorage.getItem('active_logbook_title')
|
||||
if (savedLogbookId && savedLogbookTitle) {
|
||||
setActiveLogbookId(savedLogbookId)
|
||||
setActiveLogbookTitle(savedLogbookTitle)
|
||||
}
|
||||
setIsViewerMode(false)
|
||||
|
||||
if (params.has('token')) {
|
||||
setIsAcceptingInvite(true)
|
||||
return
|
||||
}
|
||||
|
||||
setIsAcceptingInvite(false)
|
||||
|
||||
const openLogbookId = params.get('logbook')
|
||||
if (openLogbookId) {
|
||||
sessionStorage.setItem(PENDING_PUSH_LOGBOOK_KEY, openLogbookId)
|
||||
const cleanUrl = new URL(window.location.href)
|
||||
cleanUrl.searchParams.delete('logbook')
|
||||
window.history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
`${cleanUrl.pathname}${cleanUrl.search}${cleanUrl.hash}`
|
||||
)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleAuthenticated = () => {
|
||||
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
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
const session = await checkServerSession()
|
||||
if (cancelled) return
|
||||
|
||||
if (session.authenticated) {
|
||||
persistSessionUserId(session.userId)
|
||||
}
|
||||
|
||||
// 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')
|
||||
if (savedLogbookId && savedLogbookTitle) {
|
||||
setActiveLogbookId(savedLogbookId)
|
||||
setActiveLogbookTitle(savedLogbookTitle)
|
||||
}
|
||||
}
|
||||
// authenticated + crypto but no userId: stay on login (enforceUnlockedSession guards active UI)
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
console.warn('Session restore failed:', err)
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [clearAuthenticatedAppState])
|
||||
|
||||
useEffect(() => {
|
||||
syncRouteFromLocation()
|
||||
window.addEventListener('popstate', syncRouteFromLocation)
|
||||
return () => window.removeEventListener('popstate', syncRouteFromLocation)
|
||||
}, [syncRouteFromLocation])
|
||||
|
||||
const openDemo = useCallback(() => {
|
||||
window.history.pushState({}, document.title, '/demo')
|
||||
setIsDemoMode(true)
|
||||
setIsViewerMode(false)
|
||||
setIsAcceptingInvite(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
registerNavigation({
|
||||
setActiveTab,
|
||||
setSelectedEntryId: setTourSelectedEntryId,
|
||||
setFeedbackOpen: setTourFeedbackOpen
|
||||
})
|
||||
}, [registerNavigation])
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && activeLogbookId) {
|
||||
setDemoHighlightEntryId(getStoredDemoFirstEntryId())
|
||||
}
|
||||
}, [isAuthenticated, activeLogbookId])
|
||||
|
||||
const selectLogbook = (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 openLogbookById = useCallback(
|
||||
async (logbookId: string) => {
|
||||
try {
|
||||
const books = await fetchLogbooks()
|
||||
const match = books.find((b) => b.id === logbookId)
|
||||
if (match) {
|
||||
selectLogbook(match.id, match.title)
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to resolve logbook from push:', err)
|
||||
}
|
||||
selectLogbook(logbookId, `${logbookId.slice(0, 8)}…`)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const consumePendingPushLogbook = useCallback(() => {
|
||||
const pending = sessionStorage.getItem(PENDING_PUSH_LOGBOOK_KEY)
|
||||
if (!pending) return
|
||||
sessionStorage.removeItem(PENDING_PUSH_LOGBOOK_KEY)
|
||||
void openLogbookById(pending)
|
||||
}, [openLogbookById])
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
consumePendingPushLogbook()
|
||||
}
|
||||
}, [isAuthenticated, consumePendingPushLogbook])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !('serviceWorker' in navigator)) return
|
||||
|
||||
const onSwMessage = (event: MessageEvent) => {
|
||||
if (event.data?.type === 'OPEN_LOGBOOK' && typeof event.data.logbookId === 'string') {
|
||||
void openLogbookById(event.data.logbookId)
|
||||
}
|
||||
}
|
||||
|
||||
navigator.serviceWorker.addEventListener('message', onSwMessage)
|
||||
return () => navigator.serviceWorker.removeEventListener('message', onSwMessage)
|
||||
}, [isAuthenticated, openLogbookById])
|
||||
|
||||
const handleAuthenticated = async () => {
|
||||
setIsAuthenticated(true)
|
||||
trackPlausibleEvent(PlausibleEvents.LOGGED_IN)
|
||||
void ensurePushSubscriptionIfEnabled()
|
||||
|
||||
try {
|
||||
const demo = await seedDemoLogbookIfNeeded()
|
||||
if (demo) {
|
||||
selectLogbook(demo.logbookId, demo.title)
|
||||
if (demo.firstEntryId) {
|
||||
setDemoHighlightEntryId(demo.firstEntryId)
|
||||
}
|
||||
requestStartAfterLogin()
|
||||
consumePendingPushLogbook()
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to seed demo logbook:', err)
|
||||
}
|
||||
|
||||
const savedLogbookId = localStorage.getItem('active_logbook_id')
|
||||
const savedLogbookTitle = localStorage.getItem('active_logbook_title')
|
||||
if (savedLogbookId && savedLogbookTitle) {
|
||||
setActiveLogbookId(savedLogbookId)
|
||||
setActiveLogbookTitle(savedLogbookTitle)
|
||||
}
|
||||
consumePendingPushLogbook()
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
logoutUser()
|
||||
const handleTabChange = async (tab: AppTab) => {
|
||||
if (tab === activeTab) return
|
||||
if (!(await confirmLeave())) return
|
||||
setActiveTab(tab)
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
if (!(await confirmLeave())) return
|
||||
void logoutUser()
|
||||
setIsAuthenticated(false)
|
||||
setActiveLogbookId(null)
|
||||
setActiveLogbookTitle(null)
|
||||
setShowUserProfile(false)
|
||||
setTourSelectedEntryId(null)
|
||||
setDemoHighlightEntryId(null)
|
||||
localStorage.removeItem('active_logbook_id')
|
||||
localStorage.removeItem('active_logbook_title')
|
||||
}
|
||||
|
||||
const handleSelectLogbook = (id: string, title: string) => {
|
||||
setActiveLogbookId(id)
|
||||
setActiveLogbookTitle(title)
|
||||
localStorage.setItem('active_logbook_id', id)
|
||||
localStorage.setItem('active_logbook_title', title)
|
||||
}
|
||||
|
||||
const handleBackToDashboard = () => {
|
||||
const handleBackToDashboard = async () => {
|
||||
if (!(await confirmLeave())) return
|
||||
setActiveLogbookId(null)
|
||||
setActiveLogbookTitle(null)
|
||||
setTourSelectedEntryId(null)
|
||||
localStorage.removeItem('active_logbook_id')
|
||||
localStorage.removeItem('active_logbook_title')
|
||||
}
|
||||
|
||||
const toggleLanguage = () => {
|
||||
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
|
||||
i18n.changeLanguage(nextLang)
|
||||
}
|
||||
|
||||
const handleExitDemo = () => {
|
||||
window.history.replaceState({}, document.title, '/')
|
||||
syncRouteFromLocation()
|
||||
}
|
||||
|
||||
if (isDemoMode) {
|
||||
return (
|
||||
<div style={{ display: 'contents' }}>
|
||||
<DemoViewer onExit={handleExitDemo} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isViewerMode) {
|
||||
return (
|
||||
<div style={{ display: 'contents' }}>
|
||||
@@ -166,8 +464,9 @@ function App() {
|
||||
<InvitationAcceptance
|
||||
onAccepted={(logbookId, title) => {
|
||||
setIsAuthenticated(true)
|
||||
trackPlausibleEvent(PlausibleEvents.LOGGED_IN)
|
||||
setIsAcceptingInvite(false)
|
||||
handleSelectLogbook(logbookId, title)
|
||||
selectLogbook(logbookId, title)
|
||||
// Clean URL query parameters and hash anchor
|
||||
window.history.replaceState({}, document.title, window.location.pathname)
|
||||
}}
|
||||
@@ -183,21 +482,34 @@ function App() {
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="auth-screen">
|
||||
<AuthOnboarding onAuthenticated={handleAuthenticated} />
|
||||
<AuthOnboarding onAuthenticated={handleAuthenticated} onOpenDemo={openDemo} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const pwaInstallBanner = <PwaInstallPrompt variant="banner" />
|
||||
const pwaInstallBanner = !isActive ? <PwaInstallPrompt variant="banner" /> : null
|
||||
|
||||
const logbookReadOnly =
|
||||
activeLogbookRecord?.isShared === 1 && activeAccessRole === 'READ'
|
||||
const isLogbookOwner =
|
||||
activeAccessRole === 'OWNER' || activeLogbookRecord?.isShared !== 1
|
||||
|
||||
if (!activeLogbookId) {
|
||||
return (
|
||||
<div style={{ display: 'contents' }}>
|
||||
{pwaInstallBanner}
|
||||
<LogbookDashboard
|
||||
onSelectLogbook={handleSelectLogbook}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
{showUserProfile ? (
|
||||
<UserProfilePage
|
||||
onBack={() => setShowUserProfile(false)}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
) : (
|
||||
<LogbookDashboard
|
||||
onSelectLogbook={selectLogbook}
|
||||
onLogout={handleLogout}
|
||||
onOpenProfile={() => setShowUserProfile(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -210,13 +522,23 @@ function App() {
|
||||
{/* Active Logbook Header */}
|
||||
<header className="app-header">
|
||||
<div className="app-header-left">
|
||||
<button className="btn-back" onClick={handleBackToDashboard}>
|
||||
<button className="btn-back" onClick={handleBackToDashboard} title={t('nav.dashboard')}>
|
||||
<ChevronLeft size={16} />
|
||||
{t('nav.dashboard')}
|
||||
<span className="hide-mobile">{t('nav.dashboard')}</span>
|
||||
</button>
|
||||
<div className="app-title-area">
|
||||
<h2>{activeLogbookTitle}</h2>
|
||||
<p className="app-subtitle">{t('app.name')} / {activeLogbookId.substring(0, 8)}...</p>
|
||||
<div className="app-title-row">
|
||||
<h2>{activeLogbookTitle}</h2>
|
||||
<BetaBadge />
|
||||
{activeAccessRole && activeAccessRole !== 'OWNER' && (
|
||||
<LogbookRoleBadge role={activeAccessRole} />
|
||||
)}
|
||||
</div>
|
||||
<p className="app-subtitle">
|
||||
{activeAccessRole && activeAccessRole !== 'OWNER'
|
||||
? t('dashboard.section_shared_hint')
|
||||
: `${t('app.name')} / ${activeLogbookId?.substring(0, 8)}...`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -233,6 +555,20 @@ function App() {
|
||||
<span>{online ? 'Online' : t('sync.status_offline')}</span>
|
||||
</div>
|
||||
|
||||
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
|
||||
<Languages size={18} />
|
||||
</button>
|
||||
|
||||
<DisclaimerHeaderButton />
|
||||
|
||||
<FeedbackHeaderButton
|
||||
logbookId={activeLogbookId}
|
||||
logbookTitle={activeLogbookTitle}
|
||||
tourOpen={tourFeedbackOpen}
|
||||
onTourOpenChange={setTourFeedbackOpen}
|
||||
tourHighlight={isActive && currentStepId === 'nav_feedback'}
|
||||
/>
|
||||
|
||||
<button className="btn-icon logout" onClick={handleLogout} title={t('dashboard.logout')}>
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
@@ -245,7 +581,8 @@ function App() {
|
||||
<aside className="app-sidebar">
|
||||
<button
|
||||
className={`sidebar-btn ${activeTab === 'logs' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('logs')}
|
||||
onClick={() => void handleTabChange('logs')}
|
||||
data-tour="nav-logs"
|
||||
>
|
||||
<FileText size={18} />
|
||||
{t('nav.logs')}
|
||||
@@ -253,7 +590,8 @@ function App() {
|
||||
|
||||
<button
|
||||
className={`sidebar-btn ${activeTab === 'vessel' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('vessel')}
|
||||
onClick={() => void handleTabChange('vessel')}
|
||||
data-tour="nav-vessel"
|
||||
>
|
||||
<Ship size={18} />
|
||||
{t('nav.vessel')}
|
||||
@@ -261,7 +599,8 @@ function App() {
|
||||
|
||||
<button
|
||||
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('crew')}
|
||||
onClick={() => void handleTabChange('crew')}
|
||||
data-tour="nav-crew"
|
||||
>
|
||||
<Users size={18} />
|
||||
{t('nav.crew')}
|
||||
@@ -277,9 +616,18 @@ function App() {
|
||||
</button>
|
||||
*/}
|
||||
|
||||
<button
|
||||
className={`sidebar-btn ${activeTab === 'stats' ? 'active' : ''}`}
|
||||
onClick={() => void handleTabChange('stats')}
|
||||
data-tour="nav-stats"
|
||||
>
|
||||
<BarChart2 size={18} />
|
||||
{t('nav.stats')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`sidebar-btn ${activeTab === 'settings' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('settings')}
|
||||
onClick={() => void handleTabChange('settings')}
|
||||
>
|
||||
<Settings size={18} />
|
||||
{t('nav.settings')}
|
||||
@@ -289,15 +637,29 @@ function App() {
|
||||
{/* Tab Content Panels (Placeholder until Phase 3) */}
|
||||
<main className="app-content">
|
||||
{activeTab === 'logs' && (
|
||||
<LogEntriesList logbookId={activeLogbookId} />
|
||||
<LogEntriesList
|
||||
logbookId={activeLogbookId}
|
||||
readOnly={logbookReadOnly}
|
||||
controlledSelectedEntryId={tourSelectedEntryId}
|
||||
onSelectedEntryIdChange={setTourSelectedEntryId}
|
||||
highlightEntryId={demoHighlightEntryId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'vessel' && (
|
||||
<VesselForm logbookId={activeLogbookId} />
|
||||
<VesselForm logbookId={activeLogbookId} readOnly={logbookReadOnly || !isLogbookOwner} />
|
||||
)}
|
||||
|
||||
{activeTab === 'crew' && (
|
||||
<CrewForm logbookId={activeLogbookId} />
|
||||
<CrewForm
|
||||
logbookId={activeLogbookId}
|
||||
readOnly={logbookReadOnly}
|
||||
skipperReadOnly={!isLogbookOwner}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'stats' && activeLogbookId && activeLogbookTitle && (
|
||||
<StatsDashboard logbookId={activeLogbookId} logbookTitle={activeLogbookTitle} />
|
||||
)}
|
||||
|
||||
{/* Compass Deviation Table — für Freizeit-Skipper vorerst deaktiviert
|
||||
@@ -307,7 +669,10 @@ function App() {
|
||||
*/}
|
||||
|
||||
{activeTab === 'settings' && (
|
||||
<SettingsForm logbookId={activeLogbookId} />
|
||||
<SettingsForm
|
||||
logbookId={activeLogbookId}
|
||||
onLogbookRestored={selectLogbook}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
@@ -318,10 +683,17 @@ function App() {
|
||||
|
||||
export default function AppWrapper() {
|
||||
return (
|
||||
<DialogProvider>
|
||||
<PwaUpdatePrompt />
|
||||
<App />
|
||||
<AppFooter />
|
||||
</DialogProvider>
|
||||
<AppErrorBoundary>
|
||||
<DialogProvider>
|
||||
<UnsavedChangesProvider>
|
||||
<AppTourProvider>
|
||||
<PwaUpdatePrompt />
|
||||
<App />
|
||||
<AppTourOverlay />
|
||||
</AppTourProvider>
|
||||
<AppFooter />
|
||||
</UnsavedChangesProvider>
|
||||
</DialogProvider>
|
||||
</AppErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -46,6 +46,7 @@ export default function AccountDangerZone({ className = '' }: AccountDangerZoneP
|
||||
</div>
|
||||
|
||||
<p className="account-danger-zone__desc">{t('settings.danger_zone_desc')}</p>
|
||||
<p className="account-danger-zone__hint">{t('settings.delete_backup_hint')}</p>
|
||||
|
||||
<div className="form-actions account-danger-zone__actions">
|
||||
<button
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
|
||||
const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'
|
||||
|
||||
export default function AppFooter() {
|
||||
@@ -7,9 +9,15 @@ export default function AppFooter() {
|
||||
<span className="app-version-footer__sep" aria-hidden="true">
|
||||
·
|
||||
</span>
|
||||
<a className="app-version-footer__copyright" href="mailto:elpatron+kd@mailbox.org">
|
||||
© 2026 Markus F.J. Busche
|
||||
</a>
|
||||
<span className="app-version-footer__copyright">
|
||||
© 2026 KnorrLabs/
|
||||
<a
|
||||
href="mailto:elpatron+kd@mailbox.org"
|
||||
onClick={() => trackPlausibleEvent(PlausibleEvents.FOOTER_LINK_CLICKED)}
|
||||
>
|
||||
Markus F.J. Busche
|
||||
</a>
|
||||
</span>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { X, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import {
|
||||
getTourStepCopy,
|
||||
getTourTargetSelector,
|
||||
isCenteredTourStep,
|
||||
useAppTour
|
||||
} from '../context/AppTourContext.tsx'
|
||||
|
||||
interface SpotlightRect {
|
||||
top: number
|
||||
left: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
const TOOLTIP_EDGE_MARGIN = 16
|
||||
const TOOLTIP_ESTIMATED_HEIGHT = 240
|
||||
|
||||
function buildCutoutClipPath(rect: SpotlightRect): string {
|
||||
const right = rect.left + rect.width
|
||||
const bottom = rect.top + rect.height
|
||||
return `polygon(evenodd, 0 0, 100vw 0, 100vw 100vh, 0 100vh, 0 0, ${rect.left}px ${rect.top}px, ${right}px ${rect.top}px, ${right}px ${bottom}px, ${rect.left}px ${bottom}px, ${rect.left}px ${rect.top}px)`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const above = spotlight.top - 12 - TOOLTIP_ESTIMATED_HEIGHT
|
||||
if (above >= TOOLTIP_EDGE_MARGIN) {
|
||||
return above
|
||||
}
|
||||
|
||||
return Math.max(
|
||||
TOOLTIP_EDGE_MARGIN,
|
||||
Math.min(below, viewportBottom - TOOLTIP_ESTIMATED_HEIGHT)
|
||||
)
|
||||
}
|
||||
|
||||
export default function AppTourOverlay() {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
isActive,
|
||||
isDemoTour,
|
||||
currentStepId,
|
||||
currentStepIndex,
|
||||
totalSteps,
|
||||
nextStep,
|
||||
prevStep,
|
||||
skipTour
|
||||
} = useAppTour()
|
||||
|
||||
const [spotlight, setSpotlight] = useState<SpotlightRect | null>(null)
|
||||
const skipTourRef = useRef(skipTour)
|
||||
skipTourRef.current = skipTour
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isActive || !currentStepId || isCenteredTourStep(currentStepId)) {
|
||||
setSpotlight(null)
|
||||
return
|
||||
}
|
||||
|
||||
const updateSpotlight = () => {
|
||||
const selector = getTourTargetSelector(currentStepId)
|
||||
if (!selector) {
|
||||
setSpotlight(null)
|
||||
return
|
||||
}
|
||||
const el = document.querySelector(selector)
|
||||
if (!el) {
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
updateSpotlight()
|
||||
window.addEventListener('resize', updateSpotlight)
|
||||
window.addEventListener('scroll', updateSpotlight, true)
|
||||
const timer = window.setTimeout(updateSpotlight, 120)
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer)
|
||||
window.removeEventListener('resize', updateSpotlight)
|
||||
window.removeEventListener('scroll', updateSpotlight, true)
|
||||
}
|
||||
}, [currentStepId, isActive])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return
|
||||
document.body.classList.add('app-tour-active')
|
||||
return () => document.body.classList.remove('app-tour-active')
|
||||
}, [isActive])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive || !currentStepId || isCenteredTourStep(currentStepId)) return
|
||||
|
||||
const selector = getTourTargetSelector(currentStepId)
|
||||
if (!selector) return
|
||||
|
||||
const el = document.querySelector(selector)
|
||||
el?.classList.add('app-tour-target-active')
|
||||
return () => el?.classList.remove('app-tour-target-active')
|
||||
}, [currentStepId, isActive])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') skipTourRef.current()
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [isActive])
|
||||
|
||||
if (!isActive || !currentStepId) return null
|
||||
|
||||
const { title, body } = getTourStepCopy(currentStepId, t, { demoMode: isDemoTour })
|
||||
const centered = isCenteredTourStep(currentStepId)
|
||||
|
||||
const tooltipStyle = centered
|
||||
? undefined
|
||||
: spotlight
|
||||
? { top: computeTooltipTop(spotlight) }
|
||||
: { top: '20%' }
|
||||
|
||||
const backdropStyle = spotlight && !centered
|
||||
? { clipPath: buildCutoutClipPath(spotlight) }
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div className="app-tour-root" role="dialog" aria-modal="true" aria-label={title}>
|
||||
<div
|
||||
className={`app-tour-backdrop${spotlight && !centered ? ' app-tour-backdrop--cutout' : ''}`}
|
||||
style={backdropStyle}
|
||||
onClick={skipTour}
|
||||
/>
|
||||
|
||||
{!centered && spotlight && (
|
||||
<div
|
||||
className="app-tour-spotlight"
|
||||
style={{
|
||||
top: spotlight.top,
|
||||
left: spotlight.left,
|
||||
width: spotlight.width,
|
||||
height: spotlight.height
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={`app-tour-tooltip${centered ? ' centered' : ''}`} style={tooltipStyle}>
|
||||
<button type="button" className="app-tour-close" onClick={skipTour} aria-label={t('tour.skip')}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
<p className="app-tour-progress">
|
||||
{t('tour.progress', { current: currentStepIndex + 1, total: totalSteps })}
|
||||
</p>
|
||||
<h3 className="app-tour-title">{title}</h3>
|
||||
<p className="app-tour-body">{body}</p>
|
||||
|
||||
<div className="app-tour-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="app-tour-link"
|
||||
onClick={skipTour}
|
||||
>
|
||||
{t('tour.skip')}
|
||||
</button>
|
||||
|
||||
<div className="app-tour-nav">
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary app-tour-nav-btn"
|
||||
onClick={prevStep}
|
||||
disabled={currentStepIndex === 0}
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
{t('tour.back')}
|
||||
</button>
|
||||
<button type="button" className="btn primary app-tour-nav-btn" onClick={nextStep}>
|
||||
{currentStepIndex === totalSteps - 1 ? t('tour.finish') : t('tour.next')}
|
||||
{currentStepIndex < totalSteps - 1 && <ChevronRight size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,12 +12,16 @@ import {
|
||||
forgetUsername
|
||||
} 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'
|
||||
|
||||
interface AuthOnboardingProps {
|
||||
onAuthenticated: () => void
|
||||
onOpenDemo?: () => void
|
||||
}
|
||||
|
||||
export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps) {
|
||||
export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnboardingProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const [username, setUsername] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -45,6 +49,24 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
|
||||
const [showPinLogin, setShowPinLogin] = useState(false)
|
||||
const [pinLoginInput, setPinLoginInput] = useState('')
|
||||
|
||||
const [isNewRegistration, setIsNewRegistration] = useState(false)
|
||||
const [showDisclaimer, setShowDisclaimer] = useState(false)
|
||||
const [showHelp, setShowHelp] = useState(false)
|
||||
|
||||
const finishAuth = () => {
|
||||
if (isNewRegistration) {
|
||||
setShowDisclaimer(true)
|
||||
return
|
||||
}
|
||||
onAuthenticated()
|
||||
}
|
||||
|
||||
const handleDisclaimerAccept = () => {
|
||||
setIsNewRegistration(false)
|
||||
setShowDisclaimer(false)
|
||||
onAuthenticated()
|
||||
}
|
||||
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!username.trim()) return
|
||||
@@ -54,6 +76,7 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
|
||||
try {
|
||||
const result = await registerUser(username.trim())
|
||||
if (result.verified) {
|
||||
setIsNewRegistration(true)
|
||||
setRecoveryPhrase(result.recoveryPhrase)
|
||||
}
|
||||
} catch (err: any) {
|
||||
@@ -148,7 +171,7 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
|
||||
const activeKey = getActiveMasterKey()
|
||||
if (activeKey) {
|
||||
await setLocalPin(pinInput.trim(), pinSetupUsername, activeKey)
|
||||
onAuthenticated()
|
||||
finishAuth()
|
||||
} else {
|
||||
setError('No active master key found')
|
||||
}
|
||||
@@ -198,6 +221,11 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
|
||||
}
|
||||
}
|
||||
|
||||
// Render 0: Registration disclaimer (new accounts only, before app onboarding)
|
||||
if (showDisclaimer) {
|
||||
return <RegistrationDisclaimer variant="accept" onDismiss={handleDisclaimerAccept} />
|
||||
}
|
||||
|
||||
// Render 1: Display new registration recovery phrase
|
||||
if (recoveryPhrase) {
|
||||
return (
|
||||
@@ -247,6 +275,7 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="new-pin"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={8}
|
||||
@@ -256,6 +285,7 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
|
||||
onChange={(e) => setPinInput(e.target.value.replace(/\D/g, ''))}
|
||||
disabled={loading}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }}
|
||||
/>
|
||||
</div>
|
||||
@@ -266,7 +296,7 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={onAuthenticated}
|
||||
onClick={finishAuth}
|
||||
disabled={loading}
|
||||
>
|
||||
{t('auth.skip_pin')}
|
||||
@@ -296,6 +326,7 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
|
||||
<div className="input-group">
|
||||
<input
|
||||
type="password"
|
||||
name="pin"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxLength={8}
|
||||
@@ -305,6 +336,7 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
|
||||
onChange={(e) => setPinLoginInput(e.target.value.replace(/\D/g, ''))}
|
||||
disabled={loading}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }}
|
||||
/>
|
||||
</div>
|
||||
@@ -380,10 +412,14 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
|
||||
|
||||
// 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" />
|
||||
<h1>{t('app.name')}</h1>
|
||||
<div className="auth-brand-title-row">
|
||||
<h1>{t('app.name')}</h1>
|
||||
<BetaBadge />
|
||||
</div>
|
||||
<p className="tagline">{t('auth.tagline')}</p>
|
||||
</div>
|
||||
|
||||
@@ -499,6 +535,16 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
|
||||
<div style={{ flex: 1, height: '1px', background: 'rgba(255,255,255,0.1)' }}></div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => onOpenDemo?.()}
|
||||
disabled={loading}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{t('auth.explore_demo')}
|
||||
</button>
|
||||
|
||||
{/* Registration form */}
|
||||
<form onSubmit={handleRegister} style={{ display: 'flex', flexDirection: 'column', gap: '16px', width: '100%' }}>
|
||||
<div className="input-group">
|
||||
@@ -527,15 +573,23 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
|
||||
</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'}
|
||||
</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,19 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface BetaBadgeProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function BetaBadge({ className = '' }: BetaBadgeProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`beta-badge ${className}`.trim()}
|
||||
title={t('app.beta_hint')}
|
||||
aria-label={t('app.beta_hint')}
|
||||
>
|
||||
{t('app.beta')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -5,12 +5,14 @@ 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 { useDialog } from './ModalDialog.tsx'
|
||||
import { Users, User, Plus, Trash2, Edit2, Save, X, Check, Camera } from 'lucide-react'
|
||||
|
||||
interface CrewFormProps {
|
||||
logbookId: string
|
||||
readOnly?: boolean
|
||||
skipperReadOnly?: boolean
|
||||
preloadedData?: any[]
|
||||
}
|
||||
|
||||
@@ -33,9 +35,15 @@ interface DecryptedCrew {
|
||||
data: CrewMemberData
|
||||
}
|
||||
|
||||
export default function CrewForm({ logbookId, readOnly = false, preloadedData }: CrewFormProps) {
|
||||
export default function CrewForm({
|
||||
logbookId,
|
||||
readOnly = false,
|
||||
skipperReadOnly = false,
|
||||
preloadedData
|
||||
}: CrewFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const { showConfirm } = useDialog()
|
||||
const skipperFormReadOnly = readOnly || skipperReadOnly
|
||||
|
||||
// Skipper profile state
|
||||
const [skipName, setSkipName] = useState('')
|
||||
@@ -191,7 +199,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
|
||||
|
||||
const handleSaveSkipper = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (readOnly) return
|
||||
if (skipperFormReadOnly) return
|
||||
setSavingSkipper(true)
|
||||
setError(null)
|
||||
setSkipperSuccess(false)
|
||||
@@ -236,6 +244,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
|
||||
})
|
||||
|
||||
setSkipperSuccess(true)
|
||||
trackPlausibleEvent(PlausibleEvents.CREW_SAVED, { role: 'skipper', action: 'update' })
|
||||
setTimeout(() => setSkipperSuccess(false), 3000)
|
||||
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
@@ -337,6 +346,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
|
||||
}
|
||||
|
||||
setShowMemberForm(false)
|
||||
trackPlausibleEvent(PlausibleEvents.CREW_SAVED, { role: 'crew', action: isNew ? 'create' : 'update' })
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
} catch (err: any) {
|
||||
console.error('Failed to save crew member:', err)
|
||||
@@ -394,10 +404,14 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
|
||||
|
||||
{error && <div className="auth-error mb-4">{error}</div>}
|
||||
|
||||
{skipperReadOnly && !readOnly && (
|
||||
<p className="help-text mb-4">{t('crew.skipper_read_only_hint')}</p>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSaveSkipper} className="vessel-form">
|
||||
<div className="form-grid">
|
||||
<div className="vessel-photo-wrapper">
|
||||
<div className="vessel-photo-preview" onClick={readOnly ? undefined : () => skipFileInputRef.current?.click()} style={{ cursor: readOnly ? 'default' : 'pointer' }}>
|
||||
<div className="vessel-photo-preview" onClick={skipperFormReadOnly ? undefined : () => skipFileInputRef.current?.click()} style={{ cursor: skipperFormReadOnly ? 'default' : 'pointer' }}>
|
||||
{skipPhoto ? (
|
||||
<img src={skipPhoto} alt={skipName || 'Skipper'} className="vessel-photo" />
|
||||
) : (
|
||||
@@ -405,7 +419,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
|
||||
<User size={48} className="placeholder-icon" />
|
||||
</div>
|
||||
)}
|
||||
{!readOnly && (
|
||||
{!skipperFormReadOnly && (
|
||||
<div className="vessel-photo-overlay">
|
||||
<Camera size={24} />
|
||||
<span>{skipPhoto ? t('vessel.photo_change') : t('vessel.photo_add')}</span>
|
||||
@@ -413,7 +427,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!readOnly && (
|
||||
{!skipperFormReadOnly && (
|
||||
<div className="vessel-photo-actions">
|
||||
<button
|
||||
type="button"
|
||||
@@ -452,6 +466,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
|
||||
try {
|
||||
const resized = await resizeImageFile(file)
|
||||
setSkipPhoto(resized)
|
||||
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: 'crew', role: 'skipper' })
|
||||
} catch (err: any) {
|
||||
setSkipPhotoError(err.message || 'Failed to process image')
|
||||
}
|
||||
@@ -469,7 +484,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
|
||||
className="input-text"
|
||||
value={skipName}
|
||||
onChange={(e) => setSkipName(e.target.value)}
|
||||
disabled={savingSkipper || readOnly}
|
||||
disabled={savingSkipper || skipperFormReadOnly}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -481,7 +496,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
|
||||
className="input-text"
|
||||
value={skipAddress}
|
||||
onChange={(e) => setSkipAddress(e.target.value)}
|
||||
disabled={savingSkipper || readOnly}
|
||||
disabled={savingSkipper || skipperFormReadOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -492,7 +507,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
|
||||
className="input-text"
|
||||
value={skipBirthDate}
|
||||
onChange={(e) => setSkipBirthDate(e.target.value)}
|
||||
disabled={savingSkipper || readOnly}
|
||||
disabled={savingSkipper || skipperFormReadOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -503,7 +518,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
|
||||
className="input-text"
|
||||
value={skipPhone}
|
||||
onChange={(e) => setSkipPhone(e.target.value)}
|
||||
disabled={savingSkipper || readOnly}
|
||||
disabled={savingSkipper || skipperFormReadOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -514,7 +529,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
|
||||
className="input-text"
|
||||
value={skipNationality}
|
||||
onChange={(e) => setSkipNationality(e.target.value)}
|
||||
disabled={savingSkipper || readOnly}
|
||||
disabled={savingSkipper || skipperFormReadOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -525,7 +540,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
|
||||
className="input-text"
|
||||
value={skipPassport}
|
||||
onChange={(e) => setSkipPassport(e.target.value)}
|
||||
disabled={savingSkipper || readOnly}
|
||||
disabled={savingSkipper || skipperFormReadOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -536,7 +551,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
|
||||
className="input-text"
|
||||
value={skipBloodType}
|
||||
onChange={(e) => setSkipBloodType(e.target.value)}
|
||||
disabled={savingSkipper || readOnly}
|
||||
disabled={savingSkipper || skipperFormReadOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -547,7 +562,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
|
||||
className="input-text"
|
||||
value={skipAllergies}
|
||||
onChange={(e) => setSkipAllergies(e.target.value)}
|
||||
disabled={savingSkipper || readOnly}
|
||||
disabled={savingSkipper || skipperFormReadOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -558,12 +573,12 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
|
||||
className="input-text"
|
||||
value={skipDiseases}
|
||||
onChange={(e) => setSkipDiseases(e.target.value)}
|
||||
disabled={savingSkipper || readOnly}
|
||||
disabled={savingSkipper || skipperFormReadOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!readOnly && (
|
||||
{!skipperFormReadOnly && (
|
||||
<div className="form-actions">
|
||||
{skipperSuccess && (
|
||||
<div className="success-toast">
|
||||
@@ -659,6 +674,7 @@ export default function CrewForm({ logbookId, readOnly = false, preloadedData }:
|
||||
try {
|
||||
const resized = await resizeImageFile(file)
|
||||
setMemPhoto(resized)
|
||||
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: 'crew', role: 'crew' })
|
||||
} catch (err: any) {
|
||||
setMemPhotoError(err.message || 'Failed to process image')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import VesselForm from './VesselForm.tsx'
|
||||
import CrewForm from './CrewForm.tsx'
|
||||
import LogEntriesList from './LogEntriesList.tsx'
|
||||
import { Ship, Users, FileText, Lock, Globe, ChevronLeft, UserPlus } from 'lucide-react'
|
||||
import { buildPublicDemoFixture, type PublicDemoFixture } from '../services/demoLogbookData.js'
|
||||
import { useAppTour, type AppTab } from '../context/AppTourContext.tsx'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
|
||||
interface DemoViewerProps {
|
||||
onExit: () => void
|
||||
}
|
||||
|
||||
export default function DemoViewer({ onExit }: DemoViewerProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { registerNavigation, registerDemoTourContext, startTour } = useAppTour()
|
||||
const [activeTab, setActiveTab] = useState<AppTab>('logs')
|
||||
const [tourSelectedEntryId, setTourSelectedEntryId] = useState<string | null>(null)
|
||||
const [fixture, setFixture] = useState<PublicDemoFixture>(() => buildPublicDemoFixture())
|
||||
|
||||
useEffect(() => {
|
||||
trackPlausibleEvent(PlausibleEvents.DEMO_OPENED)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setFixture(buildPublicDemoFixture())
|
||||
}, [i18n.language])
|
||||
|
||||
useEffect(() => {
|
||||
registerNavigation({
|
||||
setActiveTab,
|
||||
setSelectedEntryId: setTourSelectedEntryId,
|
||||
setFeedbackOpen: () => {}
|
||||
})
|
||||
registerDemoTourContext({ firstEntryId: fixture.firstEntryId })
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
startTour({ force: true, demoMode: true })
|
||||
}, 400)
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer)
|
||||
registerDemoTourContext(null)
|
||||
}
|
||||
}, [registerNavigation, registerDemoTourContext, startTour, fixture.firstEntryId])
|
||||
|
||||
const toggleLanguage = () => {
|
||||
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
|
||||
i18n.changeLanguage(nextLang)
|
||||
}
|
||||
|
||||
const { title, yacht, crews, entries, gpsTracks, photos, firstEntryId } = fixture
|
||||
|
||||
return (
|
||||
<div className="app-layout">
|
||||
<div className="sync-progress-bar" style={{ height: '4px', background: 'linear-gradient(90deg, #f59e0b, #3b82f6)' }} />
|
||||
|
||||
<header className="app-header" style={{ borderBottom: '1px solid rgba(245, 158, 11, 0.25)' }}>
|
||||
<div className="app-header-left">
|
||||
<button className="btn-back" onClick={onExit}>
|
||||
<ChevronLeft size={16} />
|
||||
{t('demo.back_to_login')}
|
||||
</button>
|
||||
<div className="app-title-area">
|
||||
<div className="app-title-row">
|
||||
<h2>{title}</h2>
|
||||
<span className="demo-badge">{t('demo.badge')}</span>
|
||||
</div>
|
||||
<p className="app-subtitle" style={{ color: '#f59e0b', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<Lock size={12} />
|
||||
<span>{t('demo.public_banner')}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="header-actions">
|
||||
<button
|
||||
className="btn primary"
|
||||
onClick={onExit}
|
||||
style={{ width: 'auto', padding: '6px 14px', fontSize: '13px' }}
|
||||
>
|
||||
<UserPlus size={14} style={{ marginRight: '4px' }} />
|
||||
{t('demo.cta_register')}
|
||||
</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'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="app-body">
|
||||
<aside className="app-sidebar">
|
||||
<button
|
||||
className={`sidebar-btn ${activeTab === 'logs' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('logs')}
|
||||
data-tour="nav-logs"
|
||||
>
|
||||
<FileText size={18} />
|
||||
{t('nav.logs')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`sidebar-btn ${activeTab === 'vessel' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('vessel')}
|
||||
data-tour="nav-vessel"
|
||||
>
|
||||
<Ship size={18} />
|
||||
{t('nav.vessel')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('crew')}
|
||||
data-tour="nav-crew"
|
||||
>
|
||||
<Users size={18} />
|
||||
{t('nav.crew')}
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<main className="app-content">
|
||||
{activeTab === 'logs' && (
|
||||
<LogEntriesList
|
||||
logbookId="demo"
|
||||
readOnly={true}
|
||||
preloadedYacht={yacht}
|
||||
preloadedEntries={entries}
|
||||
preloadedPhotos={photos}
|
||||
preloadedGpsTracks={gpsTracks}
|
||||
controlledSelectedEntryId={tourSelectedEntryId}
|
||||
onSelectedEntryIdChange={setTourSelectedEntryId}
|
||||
highlightEntryId={firstEntryId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'vessel' && (
|
||||
<VesselForm logbookId="demo" readOnly={true} preloadedData={yacht} />
|
||||
)}
|
||||
|
||||
{activeTab === 'crew' && (
|
||||
<CrewForm logbookId="demo" readOnly={true} preloadedData={crews} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ScrollText } from 'lucide-react'
|
||||
import DisclaimerModal from './DisclaimerModal.tsx'
|
||||
|
||||
export default function DisclaimerHeaderButton() {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
onClick={() => setOpen(true)}
|
||||
title={t('disclaimer.button_title')}
|
||||
aria-label={t('disclaimer.button_title')}
|
||||
>
|
||||
<ScrollText size={18} />
|
||||
</button>
|
||||
<DisclaimerModal open={open} onClose={() => setOpen(false)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useEffect } from 'react'
|
||||
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
|
||||
|
||||
interface DisclaimerModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function DisclaimerModal({ open, onClose }: DisclaimerModalProps) {
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [open, onClose])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="disclaimer-modal-overlay" onClick={onClose}>
|
||||
<div className="disclaimer-modal-panel" onClick={(event) => event.stopPropagation()}>
|
||||
<RegistrationDisclaimer variant="view" onDismiss={onClose} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import CaptainCap from './icons/CaptainCap.tsx'
|
||||
import type { SkipperSignStatus } from '../utils/signatures.js'
|
||||
|
||||
interface EntrySkipperSignBadgeProps {
|
||||
status: SkipperSignStatus
|
||||
}
|
||||
|
||||
export default function EntrySkipperSignBadge({ status }: EntrySkipperSignBadgeProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (status === 'none') return null
|
||||
|
||||
const isValid = status === 'valid'
|
||||
const label = isValid
|
||||
? t('logs.sign_badge_skipper_title_valid')
|
||||
: t('logs.sign_badge_skipper_title_invalid')
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`entry-sign-badge entry-sign-badge--skipper ${isValid ? 'valid' : 'invalid'}`}
|
||||
title={label}
|
||||
>
|
||||
{isValid ? <CaptainCap size={14} aria-hidden /> : <AlertTriangle size={12} aria-hidden />}
|
||||
<span className={isValid ? 'entry-sign-badge__sr-label' : undefined}>
|
||||
{isValid ? t('logs.sign_badge_skipper') : t('logs.sign_badge_skipper_invalid')}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { MessageSquarePlus } from 'lucide-react'
|
||||
import FeedbackModal from './FeedbackModal.tsx'
|
||||
|
||||
interface FeedbackHeaderButtonProps {
|
||||
logbookId?: string | null
|
||||
logbookTitle?: string | null
|
||||
tourOpen?: boolean
|
||||
onTourOpenChange?: (open: boolean) => void
|
||||
tourHighlight?: boolean
|
||||
}
|
||||
|
||||
export default function FeedbackHeaderButton({
|
||||
logbookId,
|
||||
logbookTitle,
|
||||
tourOpen = false,
|
||||
onTourOpenChange,
|
||||
tourHighlight = false
|
||||
}: FeedbackHeaderButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
const [userOpen, setUserOpen] = useState(false)
|
||||
const open = tourOpen || userOpen
|
||||
|
||||
const handleClose = () => {
|
||||
setUserOpen(false)
|
||||
onTourOpenChange?.(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
onClick={() => setUserOpen(true)}
|
||||
title={t('feedback.button_title')}
|
||||
aria-label={t('feedback.button_title')}
|
||||
data-tour="feedback-button"
|
||||
>
|
||||
<MessageSquarePlus size={18} />
|
||||
</button>
|
||||
<FeedbackModal
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
logbookId={logbookId}
|
||||
logbookTitle={logbookTitle}
|
||||
tourMode={tourHighlight}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CheckCircle2, MessageSquarePlus, X } from 'lucide-react'
|
||||
import { FeedbackApiError, sendFeedback, type FeedbackCategory } from '../services/feedback.js'
|
||||
|
||||
const SUCCESS_CLOSE_DELAY_MS = 1800
|
||||
|
||||
interface FeedbackModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
logbookId?: string | null
|
||||
logbookTitle?: string | null
|
||||
tourMode?: boolean
|
||||
}
|
||||
|
||||
type SubmitState = 'idle' | 'submitting' | 'success' | 'error'
|
||||
|
||||
export default function FeedbackModal({
|
||||
open,
|
||||
onClose,
|
||||
logbookId,
|
||||
logbookTitle,
|
||||
tourMode = false
|
||||
}: FeedbackModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const [category, setCategory] = useState<FeedbackCategory>('general')
|
||||
const [contactEmail, setContactEmail] = useState('')
|
||||
const [message, setMessage] = useState('')
|
||||
const [website, setWebsite] = useState('')
|
||||
const [submitState, setSubmitState] = useState<SubmitState>('idle')
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null)
|
||||
const closeTimerRef = useRef<number | null>(null)
|
||||
const openedAtRef = useRef<number>(Date.now())
|
||||
|
||||
const isBusy = submitState === 'submitting' || submitState === 'success'
|
||||
|
||||
const clearCloseTimer = () => {
|
||||
if (closeTimerRef.current !== null) {
|
||||
window.clearTimeout(closeTimerRef.current)
|
||||
closeTimerRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => clearCloseTimer()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && !isBusy) onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [open, onClose, isBusy])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
clearCloseTimer()
|
||||
setCategory('general')
|
||||
setContactEmail('')
|
||||
setMessage('')
|
||||
setWebsite('')
|
||||
setSubmitState('idle')
|
||||
setStatusMessage(null)
|
||||
return
|
||||
}
|
||||
openedAtRef.current = Date.now()
|
||||
}, [open])
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
if (!message.trim() || submitState === 'submitting' || submitState === 'success') return
|
||||
|
||||
setSubmitState('submitting')
|
||||
setStatusMessage(null)
|
||||
|
||||
try {
|
||||
await sendFeedback({
|
||||
category,
|
||||
message: message.trim(),
|
||||
contactEmail: contactEmail.trim() || undefined,
|
||||
logbookId,
|
||||
logbookTitle,
|
||||
openedAt: openedAtRef.current,
|
||||
website
|
||||
})
|
||||
setSubmitState('success')
|
||||
setStatusMessage(t('feedback.success'))
|
||||
closeTimerRef.current = window.setTimeout(() => {
|
||||
closeTimerRef.current = null
|
||||
onClose()
|
||||
}, SUCCESS_CLOSE_DELAY_MS)
|
||||
} catch (error) {
|
||||
setSubmitState('error')
|
||||
setStatusMessage(
|
||||
error instanceof FeedbackApiError && error.code === 'NOT_CONFIGURED'
|
||||
? t('feedback.error_not_configured')
|
||||
: error instanceof FeedbackApiError && error.code === 'INVALID_EMAIL'
|
||||
? t('feedback.error_invalid_email')
|
||||
: error instanceof FeedbackApiError && error.code === 'RATE_LIMITED'
|
||||
? t('feedback.error_rate_limited')
|
||||
: error instanceof FeedbackApiError && error.code === 'SPAM_DETECTED'
|
||||
? t('feedback.error_spam')
|
||||
: t('feedback.error_send')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`disclaimer-modal-overlay${tourMode ? ' feedback-modal-overlay--tour' : ''}`}
|
||||
onClick={isBusy || tourMode ? undefined : onClose}
|
||||
>
|
||||
<div className="disclaimer-modal-panel" onClick={(event) => event.stopPropagation()}>
|
||||
<div
|
||||
className="auth-card glass registration-disclaimer registration-disclaimer--modal feedback-modal"
|
||||
data-tour="feedback-form"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="registration-disclaimer__close feedback-modal__close"
|
||||
onClick={onClose}
|
||||
disabled={isBusy || tourMode}
|
||||
aria-label={t('feedback.cancel')}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
<div className="auth-header">
|
||||
<MessageSquarePlus className="auth-icon accent" size={48} />
|
||||
<h2>{t('feedback.title')}</h2>
|
||||
</div>
|
||||
|
||||
{submitState === 'success' ? (
|
||||
<div className="feedback-status feedback-status--success" role="status" aria-live="polite">
|
||||
<CheckCircle2 size={40} aria-hidden="true" />
|
||||
<p>{statusMessage}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="registration-disclaimer__intro">{t('feedback.intro')}</p>
|
||||
|
||||
{statusMessage && submitState === 'error' && (
|
||||
<div className="feedback-status feedback-status--error" role="alert">
|
||||
<p>{statusMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className="feedback-form" onSubmit={handleSubmit}>
|
||||
<label className="feedback-form__honeypot" aria-hidden="true">
|
||||
<span>Website</span>
|
||||
<input
|
||||
type="text"
|
||||
name="website"
|
||||
value={website}
|
||||
onChange={(event) => setWebsite(event.target.value)}
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="feedback-form__field">
|
||||
<span>{t('feedback.category_label')}</span>
|
||||
<select
|
||||
value={category}
|
||||
onChange={(event) => setCategory(event.target.value as FeedbackCategory)}
|
||||
disabled={submitState === 'submitting'}
|
||||
>
|
||||
<option value="general">{t('feedback.category_general')}</option>
|
||||
<option value="bug">{t('feedback.category_bug')}</option>
|
||||
<option value="feature">{t('feedback.category_feature')}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="feedback-form__field">
|
||||
<span>{t('feedback.contact_label')}</span>
|
||||
<input
|
||||
type="email"
|
||||
value={contactEmail}
|
||||
onChange={(event) => {
|
||||
setContactEmail(event.target.value)
|
||||
if (submitState === 'error') {
|
||||
setSubmitState('idle')
|
||||
setStatusMessage(null)
|
||||
}
|
||||
}}
|
||||
placeholder={t('feedback.contact_placeholder')}
|
||||
autoComplete="email"
|
||||
maxLength={254}
|
||||
disabled={submitState === 'submitting'}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="feedback-form__field">
|
||||
<span>{t('feedback.message_label')}</span>
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(event) => {
|
||||
setMessage(event.target.value)
|
||||
if (submitState === 'error') {
|
||||
setSubmitState('idle')
|
||||
setStatusMessage(null)
|
||||
}
|
||||
}}
|
||||
placeholder={t('feedback.message_placeholder')}
|
||||
rows={6}
|
||||
maxLength={2000}
|
||||
required
|
||||
disabled={submitState === 'submitting'}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="auth-actions feedback-form__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={onClose}
|
||||
disabled={submitState === 'submitting' || tourMode}
|
||||
>
|
||||
{t('feedback.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn primary"
|
||||
disabled={submitState === 'submitting' || !message.trim()}
|
||||
>
|
||||
{submitState === 'submitting' ? t('feedback.sending') : t('feedback.send')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,14 +10,36 @@ import {
|
||||
} from '../services/auth.js'
|
||||
import { decryptJson, encryptBuffer } from '../services/crypto.js'
|
||||
import { saveLogbookKey } from '../services/logbookKeys.js'
|
||||
import { parseCollaborationRole } from '../services/logbook.js'
|
||||
import { syncLogbook } from '../services/sync.js'
|
||||
import { db } from '../services/db.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { apiJson } from '../services/api.js'
|
||||
|
||||
interface InvitationAcceptanceProps {
|
||||
onAccepted: (logbookId: string, title: string) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
type LocalizedError =
|
||||
| { source: 'i18n'; key: string }
|
||||
| { source: 'raw'; text: string }
|
||||
|
||||
const resolveLocalizedError = (
|
||||
error: LocalizedError | null,
|
||||
t: (key: string) => string
|
||||
): string | null => {
|
||||
if (!error) return null
|
||||
return error.source === 'i18n' ? t(error.key) : error.text
|
||||
}
|
||||
|
||||
const localizedErrorFromMessage = (
|
||||
message: string | undefined,
|
||||
fallbackKey: string
|
||||
): LocalizedError => {
|
||||
return message ? { source: 'raw', text: message } : { source: 'i18n', key: fallbackKey }
|
||||
}
|
||||
|
||||
const hexToBuffer = (hex: string): ArrayBuffer => {
|
||||
const bytes = new Uint8Array(hex.length / 2)
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
@@ -31,7 +53,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [accepting, setAccepting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [error, setError] = useState<LocalizedError | null>(null)
|
||||
|
||||
const [token, setToken] = useState('')
|
||||
const [logbookKey, setLogbookKey] = useState<ArrayBuffer | null>(null)
|
||||
@@ -45,7 +67,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
||||
const [username, setUsername] = useState('')
|
||||
const [loginMode, setLoginMode] = useState<'options' | 'register'>('options')
|
||||
const [regUsername, setRegUsername] = useState('')
|
||||
const [authError, setAuthError] = useState<string | null>(null)
|
||||
const [authError, setAuthError] = useState<LocalizedError | null>(null)
|
||||
|
||||
const [recoveryPhrase, setRecoveryPhrase] = useState<string | null>(null)
|
||||
const [showRecoveryFallback, setShowRecoveryFallback] = useState(false)
|
||||
@@ -54,7 +76,8 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
||||
|
||||
const autoAcceptStarted = useRef(false)
|
||||
|
||||
const isDe = i18n.language.startsWith('de')
|
||||
const errorText = resolveLocalizedError(error, t)
|
||||
const authErrorText = resolveLocalizedError(authError, t)
|
||||
|
||||
const sessionReady = (): boolean => {
|
||||
return !!(getActiveMasterKey() && localStorage.getItem('active_userid'))
|
||||
@@ -80,19 +103,15 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
||||
setLogbookKey(hexToBuffer(hexKey))
|
||||
} catch (err) {
|
||||
console.error('Invalid key in URL fragment:', err)
|
||||
setError(isDe
|
||||
? 'Der Einladungslink ist kryptografisch ungültig (Schlüssel fehlerhaft).'
|
||||
: 'The invitation link is cryptographically invalid (corrupted key).')
|
||||
setError({ source: 'i18n', key: 'invitation.error_invalid_key' })
|
||||
}
|
||||
} else {
|
||||
setError(isDe
|
||||
? 'Der Einladungslink enthält keinen Entschlüsselungsschlüssel (#key=...). Bitte den vollständigen Link vom Eigner verwenden.'
|
||||
: 'The invitation link is missing the decryption key (#key=...). Please use the complete link from the owner.')
|
||||
setError({ source: 'i18n', key: 'invitation.error_missing_key' })
|
||||
}
|
||||
|
||||
const rand = Math.floor(1000 + Math.random() * 9000)
|
||||
setRegUsername(`CrewSkipper_${rand}`)
|
||||
}, [isDe])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (token && logbookKey) {
|
||||
@@ -107,14 +126,13 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
||||
const res = await fetch(`/api/collaboration/invite-details?token=${token}`)
|
||||
|
||||
if (res.status === 410) {
|
||||
setError(isDe
|
||||
? 'Diese Einladung ist abgelaufen (48 Stunden gültig).'
|
||||
: 'This invitation link has expired (valid for 48 hours only).')
|
||||
setError({ source: 'i18n', key: 'invitation.error_expired' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(isDe ? 'Einladungstoken ungültig.' : 'Failed to verify invitation token.')
|
||||
setError({ source: 'i18n', key: 'invitation.error_invalid_token' })
|
||||
return
|
||||
}
|
||||
|
||||
const details = await res.json()
|
||||
@@ -127,7 +145,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
||||
setDecryptedTitle(title)
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load invitation details:', err)
|
||||
setError(err.message || (isDe ? 'Einladungsdetails konnten nicht geladen werden.' : 'Invitation details could not be retrieved.'))
|
||||
setError(localizedErrorFromMessage(err.message, 'invitation.error_load_failed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -138,9 +156,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
||||
const activeUserId = localStorage.getItem('active_userid')
|
||||
if (!masterKey || !activeUserId) {
|
||||
autoAcceptStarted.current = false
|
||||
setError(isDe
|
||||
? 'Sitzung unvollständig — bitte erneut anmelden (Benutzer-ID fehlt).'
|
||||
: 'Incomplete session — please log in again (user ID missing).')
|
||||
setError({ source: 'i18n', key: 'invitation.error_incomplete_session' })
|
||||
setIsLoggedIn(false)
|
||||
return
|
||||
}
|
||||
@@ -162,12 +178,8 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
||||
)
|
||||
const encrypted = await encryptBuffer(logbookKey, aesMasterKey)
|
||||
|
||||
const res = await fetch('/api/collaboration/accept', {
|
||||
const acceptResult = await apiJson<{ role: string; logbookId: string }>('/api/collaboration/accept', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-User-Id': activeUserId
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
encryptedLogbookKey: encrypted.ciphertext,
|
||||
@@ -175,11 +187,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
||||
tag: encrypted.tag
|
||||
})
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const serverError = await res.json().catch(() => ({}))
|
||||
throw new Error(serverError.error || (isDe ? 'Beitritt auf dem Server fehlgeschlagen.' : 'Failed to join logbook on the server.'))
|
||||
}
|
||||
const collaborationRole = parseCollaborationRole(acceptResult.role, 'invitation accept')
|
||||
|
||||
await saveLogbookKey(logbookId, logbookKey)
|
||||
|
||||
@@ -189,20 +197,22 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
||||
encryptedTitle: rawEncryptedTitle,
|
||||
updatedAt: new Date().toISOString(),
|
||||
isSynced: 1,
|
||||
isShared: 1
|
||||
isShared: 1,
|
||||
collaborationRole
|
||||
})
|
||||
}
|
||||
|
||||
await syncLogbook(logbookId)
|
||||
trackPlausibleEvent(PlausibleEvents.INVITE_ACCEPTED)
|
||||
onAccepted(logbookId, decryptedTitle)
|
||||
} catch (err: any) {
|
||||
console.error('Accepting invitation failed:', err)
|
||||
setError(err.message || (isDe ? 'Beitritt fehlgeschlagen.' : 'Acceptance failed.'))
|
||||
setError(localizedErrorFromMessage(err.message, 'invitation.error_accept_failed'))
|
||||
autoAcceptStarted.current = false
|
||||
} finally {
|
||||
setAccepting(false)
|
||||
}
|
||||
}, [logbookId, logbookKey, token, rawEncryptedTitle, decryptedTitle, onAccepted, isDe])
|
||||
}, [logbookId, logbookKey, token, rawEncryptedTitle, decryptedTitle, onAccepted])
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || accepting || autoAcceptStarted.current) return
|
||||
@@ -238,7 +248,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
||||
if (resolvedUser) setUsername(resolvedUser)
|
||||
setShowRecoveryFallback(true)
|
||||
} catch (err: any) {
|
||||
setAuthError(err.message || (isDe ? 'Passkey-Anmeldung fehlgeschlagen.' : 'Passkey authentication failed.'))
|
||||
setAuthError(localizedErrorFromMessage(err.message, 'invitation.error_login_failed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -250,9 +260,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
||||
|
||||
const resolvedUser = (username.trim() || encryptedPayloads.username || '').trim()
|
||||
if (!resolvedUser) {
|
||||
setAuthError(isDe
|
||||
? 'Benutzername konnte nicht ermittelt werden — bitte erneut anmelden.'
|
||||
: 'Could not determine username — please try logging in again.')
|
||||
setAuthError({ source: 'i18n', key: 'invitation.error_username_missing' })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -265,10 +273,10 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
||||
setIsLoggedIn(true)
|
||||
setUsername(resolvedUser)
|
||||
} else {
|
||||
setAuthError(t('auth.error_incorrect_recovery'))
|
||||
setAuthError({ source: 'i18n', key: 'auth.error_incorrect_recovery' })
|
||||
}
|
||||
} catch (err: any) {
|
||||
setAuthError(err.message || t('auth.error_decryption_failed'))
|
||||
setAuthError(localizedErrorFromMessage(err.message, 'auth.error_decryption_failed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -288,7 +296,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
||||
setRecoveryPhrase(result.recoveryPhrase)
|
||||
}
|
||||
} catch (err: any) {
|
||||
setAuthError(err.message || (isDe ? 'Registrierung fehlgeschlagen.' : 'Registration failed.'))
|
||||
setAuthError(localizedErrorFromMessage(err.message, 'invitation.error_register_failed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -347,14 +355,14 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
||||
/>
|
||||
<div className="auth-actions mt-4">
|
||||
<button type="button" className="btn secondary" onClick={() => setShowRecoveryFallback(false)}>
|
||||
{isDe ? 'Zurück' : 'Back'}
|
||||
{t('auth.back')}
|
||||
</button>
|
||||
<button type="submit" className="btn primary" disabled={loading}>
|
||||
{t('auth.decrypt_logbook')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{authError && <div className="auth-error mt-4">{authError}</div>}
|
||||
{authErrorText && <div className="auth-error mt-4">{authErrorText}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -364,12 +372,10 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
||||
<div className="auth-card glass">
|
||||
<div className="auth-header">
|
||||
<Ship className="auth-icon accent spin" size={48} />
|
||||
<h2>{accepting ? (isDe ? 'Beitritt...' : 'Joining...') : (isDe ? 'Einladung wird geprüft...' : 'Checking Invitation...')}</h2>
|
||||
<h2>{accepting ? t('invitation.loading_joining') : t('invitation.loading_checking')}</h2>
|
||||
</div>
|
||||
<p className="recovery-warning">
|
||||
{accepting
|
||||
? (isDe ? 'Logbuch wird freigeschaltet und synchronisiert...' : 'Unlocking logbook and syncing data...')
|
||||
: (isDe ? 'Lade Verschlüsselungsschlüssel...' : 'Retrieving encryption key...')}
|
||||
{accepting ? t('invitation.loading_unlocking') : t('invitation.loading_retrieving_key')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
@@ -380,12 +386,12 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
||||
<div className="auth-card glass">
|
||||
<div className="auth-header">
|
||||
<AlertTriangle className="auth-icon warn" size={48} />
|
||||
<h2>{isDe ? 'Einladungsfehler' : 'Invitation Error'}</h2>
|
||||
<h2>{t('invitation.error_title')}</h2>
|
||||
</div>
|
||||
<p className="recovery-warning" style={{ color: '#ef4444' }}>{error}</p>
|
||||
<p className="recovery-warning" style={{ color: '#ef4444' }}>{errorText}</p>
|
||||
<div className="auth-actions mt-6">
|
||||
<button className="btn primary" onClick={onCancel} style={{ width: '100%' }}>
|
||||
{isDe ? 'Zurück zum Start' : 'Back to Dashboard'}
|
||||
{t('invitation.back_to_start')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -396,18 +402,18 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
||||
<div className="auth-card glass">
|
||||
<div className="auth-header">
|
||||
<ShieldCheck className="auth-icon success" size={48} style={{ color: '#10b981' }} />
|
||||
<h2>{isDe ? 'Logbuch-Einladung' : 'Logbook Invitation'}</h2>
|
||||
<h2>{t('invitation.title')}</h2>
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: 'center', margin: '20px 0', padding: '16px', background: 'rgba(255,255,255,0.03)', borderRadius: '12px' }}>
|
||||
<p style={{ margin: '0 0 8px 0', fontSize: '13px', color: '#64748b', textTransform: 'uppercase' }}>
|
||||
{isDe ? 'Einladung von' : 'INVITED BY'}
|
||||
{t('invitation.invited_by')}
|
||||
</p>
|
||||
<p style={{ margin: '0 0 16px 0', fontSize: '18px', fontWeight: 600, color: '#f1f5f9' }}>
|
||||
Skipper {ownerUsername}
|
||||
</p>
|
||||
<p style={{ margin: '0 0 8px 0', fontSize: '13px', color: '#64748b', textTransform: 'uppercase' }}>
|
||||
{isDe ? 'Schiff / Logbuch' : 'VESSEL / LOGBOOK'}
|
||||
{t('invitation.vessel_logbook')}
|
||||
</p>
|
||||
<p style={{ margin: 0, fontSize: '20px', fontWeight: 700, color: '#fbbf24' }}>
|
||||
{decryptedTitle}
|
||||
@@ -417,12 +423,10 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
||||
{isLoggedIn ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', width: '100%' }}>
|
||||
<p style={{ fontSize: '14px', color: '#94a3b8', textAlign: 'center', lineHeight: '145%' }}>
|
||||
{isDe
|
||||
? `Angemeldet als ${username}. Beitritt wird vorbereitet...`
|
||||
: `Signed in as ${username}. Preparing to join...`}
|
||||
{t('invitation.signed_in_preparing', { username })}
|
||||
</p>
|
||||
<button className="btn primary" onClick={handleAccept} disabled={accepting} style={{ width: '100%' }}>
|
||||
{accepting ? (isDe ? 'Beitritt...' : 'Joining...') : (isDe ? 'Erneut beitreten' : 'Join again')}
|
||||
{accepting ? t('invitation.loading_joining') : t('invitation.join_again')}
|
||||
<ArrowRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -431,27 +435,25 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
||||
{loginMode === 'options' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
<p style={{ fontSize: '13.5px', color: '#94a3b8', textAlign: 'center', lineHeight: '145%' }}>
|
||||
{isDe
|
||||
? 'Melden Sie sich an oder registrieren Sie ein Konto, um dem Logbuch beizutreten.'
|
||||
: 'Sign in or register an account to join this logbook.'}
|
||||
{t('invitation.login_or_register_hint')}
|
||||
</p>
|
||||
|
||||
<button className="btn primary" onClick={handleLogin} disabled={loading} style={{ width: '100%', padding: '14px' }}>
|
||||
<LogIn size={16} />
|
||||
{isDe ? 'Mit Passkey anmelden' : 'Log In with Passkey'}
|
||||
{t('auth.login')}
|
||||
</button>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', margin: '8px 0' }}>
|
||||
<div style={{ flex: 1, height: '1px', background: 'rgba(255,255,255,0.08)' }} />
|
||||
<span style={{ padding: '0 10px', fontSize: '12px', color: '#64748b' }}>
|
||||
{isDe ? 'ODER NEU REGISTRIEREN' : 'OR SIGN UP'}
|
||||
{t('invitation.or_sign_up')}
|
||||
</span>
|
||||
<div style={{ flex: 1, height: '1px', background: 'rgba(255,255,255,0.08)' }} />
|
||||
</div>
|
||||
|
||||
<button className="btn secondary" onClick={() => setLoginMode('register')} style={{ width: '100%' }}>
|
||||
<UserPlus size={16} />
|
||||
{isDe ? 'Neues Crew-Konto erstellen' : 'Register New Crew Account'}
|
||||
{t('invitation.register_crew_account')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -460,7 +462,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
||||
<form onSubmit={handleRegister} style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
<div className="input-group">
|
||||
<label style={{ display: 'block', fontSize: '13px', color: '#94a3b8', marginBottom: '6px' }}>
|
||||
{isDe ? 'Benutzername' : 'Username'}
|
||||
{t('invitation.username_label')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -472,23 +474,23 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
||||
</div>
|
||||
<div className="auth-actions">
|
||||
<button type="button" className="btn secondary" onClick={() => setLoginMode('options')}>
|
||||
{isDe ? 'Zurück' : 'Back'}
|
||||
{t('auth.back')}
|
||||
</button>
|
||||
<button type="submit" className="btn primary" disabled={!regUsername.trim() || loading}>
|
||||
{isDe ? 'Passkey erstellen' : 'Create Passkey'}
|
||||
{t('invitation.create_passkey')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{authError && <div className="auth-error mt-4" style={{ fontSize: '13px' }}>{authError}</div>}
|
||||
{authErrorText && <div className="auth-error mt-4" style={{ fontSize: '13px' }}>{authErrorText}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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} />
|
||||
{isDe ? 'English' : 'Deutsch'}
|
||||
{i18n.language.startsWith('de') ? t('invitation.switch_language_en') : t('invitation.switch_language_de')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { db } from '../services/db.js'
|
||||
import { getActiveMasterKey } from '../services/auth.js'
|
||||
@@ -7,15 +7,19 @@ import { decryptJson, encryptJson } from '../services/crypto.js'
|
||||
import { syncLogbook } from '../services/sync.js'
|
||||
import { downloadCsv, shareCsv } from '../services/csvExport.js'
|
||||
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import LogEntryEditor from './LogEntryEditor.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 {
|
||||
carryOverTankLevelsFromPreviousDay,
|
||||
carryOverFromPreviousDay,
|
||||
compareTravelDaysChronological,
|
||||
emptyTankLevels,
|
||||
formatTankLiters,
|
||||
getNextTravelDayNumber,
|
||||
hasCarryOverFromPreviousDay,
|
||||
type LogEntryTankSource,
|
||||
type TravelDaySortable
|
||||
} from '../utils/logEntryTankLevels.js'
|
||||
@@ -27,6 +31,9 @@ interface LogEntriesListProps {
|
||||
preloadedEntries?: any[]
|
||||
preloadedPhotos?: any[]
|
||||
preloadedGpsTracks?: any[]
|
||||
controlledSelectedEntryId?: string | null
|
||||
onSelectedEntryIdChange?: (id: string | null) => void
|
||||
highlightEntryId?: string | null
|
||||
}
|
||||
|
||||
interface DecryptedEntryItem {
|
||||
@@ -36,6 +43,7 @@ interface DecryptedEntryItem {
|
||||
departure: string
|
||||
destination: string
|
||||
updatedAt: string
|
||||
skipperSignStatus: SkipperSignStatus
|
||||
}
|
||||
|
||||
export default function LogEntriesList({
|
||||
@@ -44,35 +52,48 @@ export default function LogEntriesList({
|
||||
preloadedYacht,
|
||||
preloadedEntries,
|
||||
preloadedPhotos,
|
||||
preloadedGpsTracks
|
||||
preloadedGpsTracks,
|
||||
controlledSelectedEntryId,
|
||||
onSelectedEntryIdChange,
|
||||
highlightEntryId
|
||||
}: LogEntriesListProps) {
|
||||
const { t } = useTranslation()
|
||||
const { showConfirm } = useDialog()
|
||||
const [entries, setEntries] = useState<DecryptedEntryItem[]>([])
|
||||
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null)
|
||||
const [internalSelectedEntryId, setInternalSelectedEntryId] = useState<string | null>(null)
|
||||
const isEntrySelectionControlled = onSelectedEntryIdChange !== undefined
|
||||
const selectedEntryId = isEntrySelectionControlled
|
||||
? (controlledSelectedEntryId ?? null)
|
||||
: internalSelectedEntryId
|
||||
const setSelectedEntryId = (entryId: string | null) => {
|
||||
if (isEntrySelectionControlled) {
|
||||
onSelectedEntryIdChange?.(entryId)
|
||||
} else {
|
||||
setInternalSelectedEntryId(entryId)
|
||||
}
|
||||
}
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const prevSelectedEntryIdRef = useRef<string | null | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedEntryId) {
|
||||
loadEntries()
|
||||
}
|
||||
}, [logbookId, selectedEntryId])
|
||||
|
||||
const loadEntries = async () => {
|
||||
const loadEntries = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
if (readOnly && preloadedEntries) {
|
||||
const list = preloadedEntries.map((entry: any) => ({
|
||||
id: entry.payloadId || entry.id,
|
||||
date: entry.date || '',
|
||||
dayOfTravel: entry.dayOfTravel || '',
|
||||
departure: entry.departure || '',
|
||||
destination: entry.destination || '',
|
||||
updatedAt: entry.updatedAt || new Date().toISOString()
|
||||
}))
|
||||
const list: DecryptedEntryItem[] = []
|
||||
for (const entry of preloadedEntries) {
|
||||
list.push({
|
||||
id: entry.payloadId || entry.id,
|
||||
date: entry.date || '',
|
||||
dayOfTravel: entry.dayOfTravel || '',
|
||||
departure: entry.departure || '',
|
||||
destination: entry.destination || '',
|
||||
updatedAt: entry.updatedAt || new Date().toISOString(),
|
||||
skipperSignStatus: await getSkipperSignStatus(entry)
|
||||
})
|
||||
}
|
||||
|
||||
list.sort((a, b) => {
|
||||
const dateCompare = new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||
@@ -100,7 +121,8 @@ export default function LogEntriesList({
|
||||
dayOfTravel: decrypted.dayOfTravel || '',
|
||||
departure: decrypted.departure || '',
|
||||
destination: decrypted.destination || '',
|
||||
updatedAt: entry.updatedAt
|
||||
updatedAt: entry.updatedAt,
|
||||
skipperSignStatus: await getSkipperSignStatus(decrypted as Record<string, unknown>)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -119,7 +141,20 @@ export default function LogEntriesList({
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [logbookId, readOnly, preloadedEntries])
|
||||
|
||||
useEffect(() => {
|
||||
loadEntries()
|
||||
}, [loadEntries])
|
||||
|
||||
useEffect(() => {
|
||||
const prevSelectedEntryId = prevSelectedEntryIdRef.current
|
||||
prevSelectedEntryIdRef.current = selectedEntryId
|
||||
|
||||
if (prevSelectedEntryId !== undefined && prevSelectedEntryId !== null && selectedEntryId === null) {
|
||||
loadEntries()
|
||||
}
|
||||
}, [selectedEntryId, loadEntries])
|
||||
|
||||
const handleDownloadCsv = async () => {
|
||||
setExporting(true)
|
||||
@@ -131,6 +166,7 @@ export default function LogEntriesList({
|
||||
} else {
|
||||
await downloadCsv(logbookId, title)
|
||||
}
|
||||
trackPlausibleEvent(PlausibleEvents.CSV_EXPORTED)
|
||||
} catch (err: any) {
|
||||
console.error('Failed to download CSV:', err)
|
||||
setError(err.message || 'Failed to generate CSV export.')
|
||||
@@ -149,6 +185,7 @@ export default function LogEntriesList({
|
||||
} else {
|
||||
await shareCsv(logbookId, title)
|
||||
}
|
||||
trackPlausibleEvent(PlausibleEvents.CSV_SHARED)
|
||||
} catch (err: any) {
|
||||
if (err.message === 'share_unsupported') {
|
||||
const title = preloadedYacht?.name || localStorage.getItem('active_logbook_title') || 'Logbook'
|
||||
@@ -178,6 +215,7 @@ export default function LogEntriesList({
|
||||
} else {
|
||||
await downloadLogbookPagePdf(logbookId, entryId, date)
|
||||
}
|
||||
trackPlausibleEvent(PlausibleEvents.PDF_EXPORTED, { scope: 'entry' })
|
||||
} catch (err: any) {
|
||||
console.error('Failed to download PDF:', err)
|
||||
setError(err.message || 'Failed to generate PDF export.')
|
||||
@@ -203,11 +241,12 @@ export default function LogEntriesList({
|
||||
|
||||
decryptedEntries.sort(compareTravelDaysChronological)
|
||||
const previousEntry = decryptedEntries.at(-1) ?? null
|
||||
let { freshwater, fuel } = carryOverTankLevelsFromPreviousDay(previousEntry)
|
||||
let { freshwater, fuel, departure } = carryOverFromPreviousDay(previousEntry)
|
||||
|
||||
if (previousEntry && (freshwater.morning > 0 || fuel.morning > 0)) {
|
||||
if (previousEntry && hasCarryOverFromPreviousDay({ freshwater, fuel, departure })) {
|
||||
const confirmed = await showConfirm(
|
||||
t('logs.carry_over_tanks_confirm', {
|
||||
departure: departure || '—',
|
||||
fw: formatTankLiters(freshwater.morning),
|
||||
fuel: formatTankLiters(fuel.morning)
|
||||
}),
|
||||
@@ -218,6 +257,7 @@ export default function LogEntriesList({
|
||||
if (!confirmed) {
|
||||
freshwater = emptyTankLevels()
|
||||
fuel = emptyTankLevels()
|
||||
departure = ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,7 +270,7 @@ export default function LogEntriesList({
|
||||
const initialPayload = {
|
||||
date: todayStr,
|
||||
dayOfTravel: getNextTravelDayNumber(decryptedEntries),
|
||||
departure: '',
|
||||
departure,
|
||||
destination: '',
|
||||
freshwater,
|
||||
fuel,
|
||||
@@ -263,6 +303,7 @@ export default function LogEntriesList({
|
||||
|
||||
// Open immediately in details editor
|
||||
setSelectedEntryId(localId)
|
||||
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_CREATED)
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
} catch (err: any) {
|
||||
console.error('Failed to create entry:', err)
|
||||
@@ -331,7 +372,7 @@ export default function LogEntriesList({
|
||||
<Calendar size={24} className="form-icon" />
|
||||
<h2>{t('logs.title')}</h2>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<div className="section-toolbar">
|
||||
<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>
|
||||
@@ -343,9 +384,9 @@ export default function LogEntriesList({
|
||||
</button>
|
||||
|
||||
{!readOnly && (
|
||||
<button className="btn primary" onClick={handleCreate} disabled={loading || exporting} style={{ width: 'auto', padding: '8px 16px' }}>
|
||||
<button className="btn primary" onClick={handleCreate} disabled={loading || exporting} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.new_entry')}>
|
||||
<Plus size={16} />
|
||||
{t('logs.new_entry')}
|
||||
<span className="hide-mobile">{t('logs.new_entry')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -356,9 +397,14 @@ export default function LogEntriesList({
|
||||
{entries.length === 0 ? (
|
||||
<div className="dashboard-status-msg">{t('logs.no_entries')}</div>
|
||||
) : (
|
||||
<div className="logbooks-grid">
|
||||
<div className="logbooks-grid" data-tour="entry-list">
|
||||
{entries.map((item) => (
|
||||
<div key={item.id} className="logbook-card glass" onClick={() => setSelectedEntryId(item.id)}>
|
||||
<div
|
||||
key={item.id}
|
||||
className="logbook-card glass"
|
||||
data-tour={highlightEntryId === item.id ? 'entry-first' : undefined}
|
||||
onClick={() => setSelectedEntryId(item.id)}
|
||||
>
|
||||
<div className="card-icon">
|
||||
<FileText size={24} />
|
||||
</div>
|
||||
@@ -373,6 +419,7 @@ export default function LogEntriesList({
|
||||
<span className="sync-badge synced">
|
||||
{t('logs.day_of_travel')} {item.dayOfTravel}
|
||||
</span>
|
||||
<EntrySkipperSignBadge status={item.skipperSignStatus} />
|
||||
<span className="date-badge">
|
||||
{new Date(item.date).toLocaleDateString()}
|
||||
</span>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,346 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Archive, Download, Upload, Check, AlertTriangle } from 'lucide-react'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import {
|
||||
downloadBackupBlob,
|
||||
exportLogbookBackup,
|
||||
parseLogbookBackupFile,
|
||||
previewLogbookBackup,
|
||||
restoreLogbookBackup,
|
||||
type LogbookBackupFile,
|
||||
type LogbookBackupPreview
|
||||
} from '../services/logbookBackup.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { formatAppDateTime } from '../utils/dateTimeFormat.js'
|
||||
|
||||
interface LogbookBackupPanelProps {
|
||||
logbookId: string
|
||||
onRestored?: (logbookId: string, title: string) => void
|
||||
}
|
||||
|
||||
function mapBackupError(code: string, t: (key: string) => string): string {
|
||||
switch (code) {
|
||||
case 'BACKUP_PASSPHRASE_TOO_SHORT':
|
||||
return t('settings.backup_passphrase_short')
|
||||
case 'BACKUP_NOT_OWNER':
|
||||
return t('settings.backup_not_owner')
|
||||
case 'BACKUP_INVALID_JSON':
|
||||
return t('settings.backup_invalid_json')
|
||||
case 'BACKUP_INVALID_FORMAT':
|
||||
return t('settings.backup_invalid_format')
|
||||
case 'BACKUP_NOT_AUTHENTICATED':
|
||||
return t('settings.backup_not_authenticated')
|
||||
case 'BACKUP_ID_CONFLICT':
|
||||
return t('settings.backup_id_conflict')
|
||||
default:
|
||||
if (code.includes('decrypt') || code.includes('operation')) {
|
||||
return t('settings.backup_wrong_passphrase')
|
||||
}
|
||||
return code
|
||||
}
|
||||
}
|
||||
|
||||
export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBackupPanelProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { showConfirm } = useDialog()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const [exportPassphrase, setExportPassphrase] = useState('')
|
||||
const [exportConfirm, setExportConfirm] = useState('')
|
||||
const [exporting, setExporting] = useState(false)
|
||||
|
||||
const [importPassphrase, setImportPassphrase] = useState('')
|
||||
const [importFile, setImportFile] = useState<File | null>(null)
|
||||
const [importPreview, setImportPreview] = useState<LogbookBackupPreview | null>(null)
|
||||
const [parsedBackup, setParsedBackup] = useState<LogbookBackupFile | null>(null)
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [previewing, setPreviewing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
|
||||
const handleExportSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
await handleExport()
|
||||
}
|
||||
|
||||
const handleImportSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
await handleRestore()
|
||||
}
|
||||
|
||||
const handleExport = async () => {
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
|
||||
if (exportPassphrase.length < 8) {
|
||||
setError(t('settings.backup_passphrase_short'))
|
||||
return
|
||||
}
|
||||
if (exportPassphrase !== exportConfirm) {
|
||||
setError(t('settings.backup_passphrase_mismatch'))
|
||||
return
|
||||
}
|
||||
|
||||
setExporting(true)
|
||||
try {
|
||||
const { blob, filename, backup } = await exportLogbookBackup(logbookId, exportPassphrase)
|
||||
downloadBackupBlob(blob, filename)
|
||||
setSuccess(t('settings.backup_export_success', { count: backup.counts.entries }))
|
||||
setExportPassphrase('')
|
||||
setExportConfirm('')
|
||||
trackPlausibleEvent(PlausibleEvents.BACKUP_EXPORTED, {
|
||||
entries: backup.counts.entries,
|
||||
photos: backup.counts.photos
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
setError(mapBackupError(message, t))
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
setImportPreview(null)
|
||||
setParsedBackup(null)
|
||||
const file = e.target.files?.[0]
|
||||
setImportFile(file ?? null)
|
||||
if (!file) return
|
||||
|
||||
try {
|
||||
const backup = await parseLogbookBackupFile(file)
|
||||
setParsedBackup(backup)
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
setError(mapBackupError(message, t))
|
||||
setImportFile(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePreviewImport = async () => {
|
||||
if (!parsedBackup || !importPassphrase) return
|
||||
setPreviewing(true)
|
||||
setError(null)
|
||||
try {
|
||||
const preview = await previewLogbookBackup(parsedBackup, importPassphrase)
|
||||
setImportPreview(preview)
|
||||
} catch (err: unknown) {
|
||||
setImportPreview(null)
|
||||
setError(t('settings.backup_wrong_passphrase'))
|
||||
} finally {
|
||||
setPreviewing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRestore = async (options: { overwrite?: boolean; assignNewId?: boolean } = {}) => {
|
||||
if (!parsedBackup || !importPassphrase) return
|
||||
|
||||
setImporting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await restoreLogbookBackup(parsedBackup, importPassphrase, options)
|
||||
setSuccess(t('settings.backup_restore_success', { title: result.title }))
|
||||
setImportFile(null)
|
||||
setImportPassphrase('')
|
||||
setImportPreview(null)
|
||||
setParsedBackup(null)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
trackPlausibleEvent(PlausibleEvents.BACKUP_RESTORED, {
|
||||
entries: parsedBackup.counts.entries,
|
||||
photos: parsedBackup.counts.photos,
|
||||
mode: options.overwrite ? 'overwrite' : options.assignNewId ? 'new_id' : 'same_id'
|
||||
})
|
||||
onRestored?.(result.logbookId, result.title)
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
if (message === 'BACKUP_ID_CONFLICT') {
|
||||
const overwrite = await showConfirm(
|
||||
t('settings.backup_overwrite_confirm'),
|
||||
t('settings.backup_restore_title'),
|
||||
t('logs.confirm_yes'),
|
||||
t('logs.confirm_no')
|
||||
)
|
||||
if (overwrite) {
|
||||
setImporting(false)
|
||||
return handleRestore({ overwrite: true })
|
||||
}
|
||||
const asNew = await showConfirm(
|
||||
t('settings.backup_new_id_confirm'),
|
||||
t('settings.backup_restore_title'),
|
||||
t('logs.confirm_yes'),
|
||||
t('logs.confirm_no')
|
||||
)
|
||||
if (asNew) {
|
||||
setImporting(false)
|
||||
return handleRestore({ assignNewId: true })
|
||||
}
|
||||
setError(t('settings.backup_restore_cancelled'))
|
||||
} else {
|
||||
setError(mapBackupError(message, t))
|
||||
}
|
||||
} finally {
|
||||
setImporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="member-editor-card glass mt-6 backup-panel" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '24px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
|
||||
<Archive size={20} style={{ color: '#38bdf8' }} />
|
||||
<h3 style={{ margin: 0, color: '#38bdf8', fontSize: '16px' }}>
|
||||
{t('settings.backup_title')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: '0 0 20px 0' }}>
|
||||
{t('settings.backup_desc')}
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="auth-error mb-4" role="alert">
|
||||
<AlertTriangle size={16} style={{ display: 'inline', marginRight: 6, verticalAlign: 'text-bottom' }} />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="success-toast mb-4">
|
||||
<Check size={16} />
|
||||
<span>{success}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="backup-section" aria-labelledby="backup-export-heading">
|
||||
<h4 id="backup-export-heading" className="backup-section-title">
|
||||
<Download size={16} aria-hidden="true" />
|
||||
{t('settings.backup_export_title')}
|
||||
</h4>
|
||||
<p className="text-muted backup-section-desc">{t('settings.backup_export_desc')}</p>
|
||||
|
||||
<form onSubmit={handleExportSubmit} className="backup-export-form">
|
||||
<div className="input-group">
|
||||
<label htmlFor="backup-export-passphrase">{t('settings.backup_passphrase')}</label>
|
||||
<input
|
||||
id="backup-export-passphrase"
|
||||
name="backup-export-passphrase"
|
||||
type="password"
|
||||
className="input-text"
|
||||
value={exportPassphrase}
|
||||
onChange={(e) => setExportPassphrase(e.target.value)}
|
||||
placeholder={t('settings.backup_passphrase_placeholder')}
|
||||
autoComplete="new-password"
|
||||
disabled={exporting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label htmlFor="backup-export-confirm">{t('settings.backup_passphrase_confirm')}</label>
|
||||
<input
|
||||
id="backup-export-confirm"
|
||||
name="backup-export-confirm"
|
||||
type="password"
|
||||
className="input-text"
|
||||
value={exportConfirm}
|
||||
onChange={(e) => setExportConfirm(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
disabled={exporting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn primary"
|
||||
disabled={exporting || !exportPassphrase || !exportConfirm}
|
||||
>
|
||||
<Download size={16} />
|
||||
{exporting ? t('settings.backup_exporting') : t('settings.backup_export_btn')}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="backup-section backup-section--import" aria-labelledby="backup-import-heading">
|
||||
<h4 id="backup-import-heading" className="backup-section-title">
|
||||
<Upload size={16} aria-hidden="true" />
|
||||
{t('settings.backup_restore_title')}
|
||||
</h4>
|
||||
<p className="text-muted backup-section-desc">{t('settings.backup_restore_desc')}</p>
|
||||
|
||||
<form onSubmit={handleImportSubmit} className="backup-import-form">
|
||||
<div className="input-group">
|
||||
<label htmlFor="backup-import-file">{t('settings.backup_file_label')}</label>
|
||||
<input
|
||||
id="backup-import-file"
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".daagbok.json,application/json"
|
||||
className="input-text"
|
||||
onChange={handleFileChange}
|
||||
disabled={importing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{importFile && (
|
||||
<>
|
||||
<div className="input-group">
|
||||
<label htmlFor="backup-import-passphrase">{t('settings.backup_passphrase')}</label>
|
||||
<input
|
||||
id="backup-import-passphrase"
|
||||
name="backup-import-passphrase"
|
||||
type="password"
|
||||
className="input-text"
|
||||
value={importPassphrase}
|
||||
onChange={(e) => {
|
||||
setImportPassphrase(e.target.value)
|
||||
setImportPreview(null)
|
||||
}}
|
||||
autoComplete="current-password"
|
||||
disabled={importing}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="backup-actions-row">
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={handlePreviewImport}
|
||||
disabled={previewing || importing || !importPassphrase}
|
||||
>
|
||||
{previewing ? t('settings.backup_previewing') : t('settings.backup_preview_btn')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn primary"
|
||||
disabled={importing || !importPassphrase}
|
||||
>
|
||||
<Upload size={16} />
|
||||
{importing ? t('settings.backup_restoring') : t('settings.backup_restore_btn')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{importPreview && (
|
||||
<div className="backup-preview glass">
|
||||
<p className="backup-preview-title">{importPreview.title}</p>
|
||||
<ul className="backup-preview-stats">
|
||||
<li>{t('settings.backup_stat_entries', { count: importPreview.counts.entries })}</li>
|
||||
<li>{t('settings.backup_stat_photos', { count: importPreview.counts.photos })}</li>
|
||||
<li>{t('settings.backup_stat_crew', { count: importPreview.counts.crews })}</li>
|
||||
<li>{t('settings.backup_stat_tracks', { count: importPreview.counts.gpsTracks })}</li>
|
||||
</ul>
|
||||
<p className="text-muted backup-preview-date">
|
||||
{t('settings.backup_exported_at', {
|
||||
date: formatAppDateTime(importPreview.exportedAt, i18n.language)
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,23 +1,31 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import { db } from '../services/db.js'
|
||||
import { fetchLogbooks, createLogbook, deleteLogbook, type DecryptedLogbook } from '../services/logbook.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 AccountDangerZone from './AccountDangerZone.tsx'
|
||||
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, User, Wifi, WifiOff } from 'lucide-react'
|
||||
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
|
||||
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
|
||||
|
||||
interface LogbookDashboardProps {
|
||||
onSelectLogbook: (id: string, title: string) => void
|
||||
onLogout: () => void
|
||||
onOpenProfile: () => void
|
||||
}
|
||||
|
||||
export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookDashboardProps) {
|
||||
export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProfile }: LogbookDashboardProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { showConfirm } = useDialog()
|
||||
const [logbooks, setLogbooks] = useState<DecryptedLogbook[]>([])
|
||||
const [newTitle, setNewTitle] = useState('')
|
||||
const [editingLogbookId, setEditingLogbookId] = useState<string | null>(null)
|
||||
const [editingTitleDraft, setEditingTitleDraft] = useState('')
|
||||
const titleInputRef = useRef<HTMLInputElement>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -69,6 +77,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
||||
const created = await createLogbook(newTitle.trim())
|
||||
setLogbooks((prev) => [created, ...prev])
|
||||
setNewTitle('')
|
||||
trackPlausibleEvent(PlausibleEvents.LOGBOOK_CREATED)
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to create logbook')
|
||||
} finally {
|
||||
@@ -79,7 +88,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
||||
const handleDelete = async (id: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation() // Prevent selecting the logbook when clicking delete
|
||||
|
||||
if (await showConfirm(t('dashboard.delete_confirm'), t('dashboard.title'), t('logs.confirm_yes'), t('logs.confirm_no'))) {
|
||||
if (await showConfirm(t('dashboard.delete_confirm'), t('dashboard.delete_btn'), t('logs.confirm_yes'), t('logs.confirm_no'))) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
@@ -93,8 +102,51 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (editingLogbookId) {
|
||||
titleInputRef.current?.focus()
|
||||
titleInputRef.current?.select()
|
||||
}
|
||||
}, [editingLogbookId])
|
||||
|
||||
const startTitleEdit = (lb: DecryptedLogbook, e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setEditingLogbookId(lb.id)
|
||||
setEditingTitleDraft(lb.title)
|
||||
}
|
||||
|
||||
const cancelTitleEdit = () => {
|
||||
setEditingLogbookId(null)
|
||||
setEditingTitleDraft('')
|
||||
}
|
||||
|
||||
const commitTitleEdit = async (id: string) => {
|
||||
if (editingLogbookId !== id) return
|
||||
|
||||
const lb = logbooks.find((item) => item.id === id)
|
||||
const trimmedTitle = editingTitleDraft.trim()
|
||||
cancelTitleEdit()
|
||||
|
||||
if (!lb || !trimmedTitle || trimmedTitle === lb.title.trim()) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
await updateLogbookTitle(id, trimmedTitle)
|
||||
setLogbooks((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === id ? { ...item, title: trimmedTitle, updatedAt: new Date().toISOString() } : item
|
||||
)
|
||||
)
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to update logbook title')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
logoutUser()
|
||||
void logoutUser()
|
||||
onLogout()
|
||||
}
|
||||
|
||||
@@ -103,6 +155,106 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
||||
i18n.changeLanguage(nextLang)
|
||||
}
|
||||
|
||||
const ownedLogbooks = logbooks.filter((lb) => !lb.isShared)
|
||||
const sharedLogbooks = logbooks.filter((lb) => lb.isShared)
|
||||
|
||||
const renderLogbookCard = (lb: DecryptedLogbook) => {
|
||||
const isEditingTitle = editingLogbookId === lb.id
|
||||
|
||||
return (
|
||||
<div
|
||||
key={lb.id}
|
||||
className={`logbook-card glass${lb.isShared ? ' logbook-card--shared' : ''}`}
|
||||
onClick={() => onSelectLogbook(lb.id, lb.title)}
|
||||
>
|
||||
<div className="card-icon">
|
||||
<BookOpen size={24} />
|
||||
</div>
|
||||
|
||||
<div className="card-info">
|
||||
<div className="card-title-row">
|
||||
{isEditingTitle ? (
|
||||
<input
|
||||
ref={titleInputRef}
|
||||
type="text"
|
||||
className="logbook-title-inline-edit input-text"
|
||||
value={editingTitleDraft}
|
||||
onChange={(e) => setEditingTitleDraft(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
void commitTitleEdit(lb.id)
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelTitleEdit()
|
||||
}
|
||||
}}
|
||||
onBlur={() => void commitTitleEdit(lb.id)}
|
||||
disabled={loading}
|
||||
aria-label={t('dashboard.edit_title')}
|
||||
/>
|
||||
) : (
|
||||
<h3
|
||||
className={lb.isShared ? undefined : 'logbook-title-editable'}
|
||||
onClick={lb.isShared ? undefined : (e) => startTitleEdit(lb, e)}
|
||||
title={lb.isShared ? undefined : t('dashboard.edit_title')}
|
||||
>
|
||||
{lb.title}
|
||||
</h3>
|
||||
)}
|
||||
<LogbookRoleBadge role={lb.accessRole} />
|
||||
</div>
|
||||
<div className="card-meta">
|
||||
<span className={`sync-badge ${lb.isSynced ? 'synced' : 'local'}`}>
|
||||
{lb.isSynced ? t('dashboard.status_synced') : t('dashboard.status_local')}
|
||||
</span>
|
||||
{lb.isDemo && (
|
||||
<span className="demo-badge">{t('demo.badge')}</span>
|
||||
)}
|
||||
<span className="date-badge">
|
||||
{new Date(lb.updatedAt).toLocaleDateString(i18n.language, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!lb.isShared && (
|
||||
<div className="logbook-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-delete"
|
||||
onClick={(e) => handleDelete(lb.id, e)}
|
||||
title={t('dashboard.delete_btn')}
|
||||
aria-label={t('dashboard.delete_btn')}
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderLogbookSection = (
|
||||
title: string,
|
||||
items: DecryptedLogbook[],
|
||||
hint?: string
|
||||
) => (
|
||||
<div className="logbook-section">
|
||||
<div className="logbook-section-header">
|
||||
<h3>{title}</h3>
|
||||
{hint && <p className="logbook-section-hint">{hint}</p>}
|
||||
</div>
|
||||
<div className="logbooks-grid">
|
||||
{items.map(renderLogbookCard)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="dashboard-container">
|
||||
{/* Premium Dashboard Header */}
|
||||
@@ -110,7 +262,10 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
||||
<div className="header-brand">
|
||||
<Ship className="header-logo" size={32} />
|
||||
<div>
|
||||
<h1>{t('app.name')}</h1>
|
||||
<div className="header-brand-title-row">
|
||||
<h1>{t('app.name')}</h1>
|
||||
<BetaBadge />
|
||||
</div>
|
||||
<p className="subtitle">{t('app.tagline')}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -139,16 +294,26 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
||||
</div>
|
||||
|
||||
{/* Skipper profile */}
|
||||
<div className="skipper-badge">
|
||||
<User size={16} />
|
||||
<span>{username}</span>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{/* Lang toggle */}
|
||||
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
|
||||
<Languages size={18} />
|
||||
</button>
|
||||
|
||||
<DisclaimerHeaderButton />
|
||||
|
||||
<FeedbackHeaderButton />
|
||||
|
||||
{/* Logout */}
|
||||
<button className="btn-icon logout" onClick={handleLogout} title={t('dashboard.logout')}>
|
||||
<LogOut size={18} />
|
||||
@@ -196,47 +361,20 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
||||
) : logbooks.length === 0 ? (
|
||||
<div className="dashboard-status-msg glass">{t('dashboard.no_logbooks')}</div>
|
||||
) : (
|
||||
<div className="logbooks-grid">
|
||||
{logbooks.map((lb) => (
|
||||
<div key={lb.id} className="logbook-card glass" onClick={() => onSelectLogbook(lb.id, lb.title)}>
|
||||
<div className="card-icon">
|
||||
<BookOpen size={24} />
|
||||
</div>
|
||||
|
||||
<div className="card-info">
|
||||
<h3>{lb.title}</h3>
|
||||
<div className="card-meta">
|
||||
<span className={`sync-badge ${lb.isSynced ? 'synced' : 'local'}`}>
|
||||
{lb.isSynced ? t('dashboard.status_synced') : t('dashboard.status_local')}
|
||||
</span>
|
||||
<span className="date-badge">
|
||||
{new Date(lb.updatedAt).toLocaleDateString(i18n.language, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn-delete"
|
||||
onClick={(e) => handleDelete(lb.id, e)}
|
||||
title={t('dashboard.delete_btn')}
|
||||
style={{ visibility: lb.isShared ? 'hidden' : 'visible' }}
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="logbook-sections">
|
||||
{ownedLogbooks.length > 0 && renderLogbookSection(
|
||||
sharedLogbooks.length > 0 ? t('dashboard.section_owned') : t('dashboard.title'),
|
||||
ownedLogbooks
|
||||
)}
|
||||
{sharedLogbooks.length > 0 && renderLogbookSection(
|
||||
t('dashboard.section_shared'),
|
||||
sharedLogbooks,
|
||||
t('dashboard.section_shared_hint')
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<section className="dashboard-account-section" aria-label={t('settings.danger_zone_title')}>
|
||||
<AccountDangerZone />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Anchor, Eye, Users } from 'lucide-react'
|
||||
import type { LogbookAccessRole } from '../services/logbook.js'
|
||||
|
||||
interface LogbookRoleBadgeProps {
|
||||
role: LogbookAccessRole
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function LogbookRoleBadge({ role, className = '' }: LogbookRoleBadgeProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (role === 'OWNER') {
|
||||
return (
|
||||
<span className={`role-badge role-badge--owner ${className}`.trim()} title={t('dashboard.role_owner_hint')}>
|
||||
<Anchor size={12} aria-hidden="true" />
|
||||
{t('dashboard.role_owner')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (role === 'READ') {
|
||||
return (
|
||||
<span className={`role-badge role-badge--read ${className}`.trim()} title={t('dashboard.role_read_hint')}>
|
||||
<Eye size={12} aria-hidden="true" />
|
||||
{t('dashboard.role_read')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`role-badge role-badge--crew ${className}`.trim()} title={t('dashboard.role_crew_hint')}>
|
||||
<Users size={12} aria-hidden="true" />
|
||||
{t('dashboard.role_crew')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { createContext, useContext, useState, useRef } from 'react'
|
||||
import React, { createContext, useContext, useState, useRef, useCallback, useMemo } from 'react'
|
||||
|
||||
interface DialogContextType {
|
||||
showAlert: (message: string, title?: string, confirmText?: string) => Promise<void>
|
||||
@@ -25,7 +25,7 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
const resolveRef = useRef<((val: any) => void) | null>(null)
|
||||
|
||||
const showAlert = (msg: string, headerTitle?: string, btnText?: string): Promise<void> => {
|
||||
const showAlert = useCallback((msg: string, headerTitle?: string, btnText?: string): Promise<void> => {
|
||||
setMessage(msg)
|
||||
setTitle(headerTitle || '')
|
||||
setType('alert')
|
||||
@@ -35,9 +35,14 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
|
||||
return new Promise<void>((resolve) => {
|
||||
resolveRef.current = resolve
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const showConfirm = (msg: string, headerTitle?: string, btnConfirm?: string, btnCancel?: string): Promise<boolean> => {
|
||||
const showConfirm = useCallback((
|
||||
msg: string,
|
||||
headerTitle?: string,
|
||||
btnConfirm?: string,
|
||||
btnCancel?: string
|
||||
): Promise<boolean> => {
|
||||
setMessage(msg)
|
||||
setTitle(headerTitle || '')
|
||||
setType('confirm')
|
||||
@@ -48,26 +53,31 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
resolveRef.current = resolve
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleConfirm = () => {
|
||||
const handleConfirm = useCallback(() => {
|
||||
setIsOpen(false)
|
||||
if (resolveRef.current) {
|
||||
resolveRef.current(type === 'confirm' ? true : undefined)
|
||||
resolveRef.current = null
|
||||
}
|
||||
}
|
||||
}, [type])
|
||||
|
||||
const handleCancel = () => {
|
||||
const handleCancel = useCallback(() => {
|
||||
setIsOpen(false)
|
||||
if (resolveRef.current) {
|
||||
resolveRef.current(false)
|
||||
resolveRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({ showAlert, showConfirm }),
|
||||
[showAlert, showConfirm]
|
||||
)
|
||||
|
||||
return (
|
||||
<DialogContext.Provider value={{ showAlert, showConfirm }}>
|
||||
<DialogContext.Provider value={contextValue}>
|
||||
{children}
|
||||
{isOpen && (
|
||||
<div className="custom-dialog-overlay" onClick={type === 'alert' ? handleConfirm : undefined}>
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { Component, useEffect, useMemo, useRef } from 'react'
|
||||
import type { ErrorInfo, ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import L from 'leaflet'
|
||||
import type { TrackSegment } from '../services/statsAggregation.js'
|
||||
import { getTrackColor } from '../services/statsAggregation.js'
|
||||
import type { TrackWaypoint } from '../services/trackUpload.js'
|
||||
|
||||
interface MultiTrackMapProps {
|
||||
segments: TrackSegment[]
|
||||
}
|
||||
|
||||
const LINE_WEIGHT = 4
|
||||
const LINE_OPACITY = 0.88
|
||||
|
||||
function isValidWaypoint(wp: TrackWaypoint): boolean {
|
||||
return Number.isFinite(Number(wp.lat)) && Number.isFinite(Number(wp.lng))
|
||||
}
|
||||
|
||||
function toLatLngs(waypoints: TrackWaypoint[]): [number, number][] {
|
||||
return waypoints
|
||||
.filter(isValidWaypoint)
|
||||
.map((wp) => [Number(wp.lat), Number(wp.lng)] as [number, number])
|
||||
}
|
||||
|
||||
function MultiTrackMapInner({ segments }: MultiTrackMapProps) {
|
||||
const { t } = useTranslation()
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const segmentsKey = useMemo(
|
||||
() =>
|
||||
segments
|
||||
.map((seg) =>
|
||||
seg.waypoints
|
||||
.filter(isValidWaypoint)
|
||||
.map((wp) => `${seg.entryId}:${wp.lat},${wp.lng}`)
|
||||
.join('|')
|
||||
)
|
||||
.join('||'),
|
||||
[segments]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container || segments.length === 0) return
|
||||
|
||||
let cancelled = false
|
||||
const pendingFrames: number[] = []
|
||||
|
||||
const map = L.map(container, {
|
||||
zoomControl: true,
|
||||
attributionControl: true
|
||||
})
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: '© <a href="https://openstreetmap.org">OpenStreetMap</a> contributors'
|
||||
}).addTo(map)
|
||||
|
||||
L.tileLayer('https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png', {
|
||||
maxZoom: 18,
|
||||
attribution: 'Map data © <a href="http://openseamap.org">OpenSeaMap</a> contributors'
|
||||
}).addTo(map)
|
||||
|
||||
const trackGroup = L.layerGroup().addTo(map)
|
||||
const allLatLngs: [number, number][] = []
|
||||
|
||||
for (const segment of segments) {
|
||||
const latLngs = toLatLngs(segment.waypoints)
|
||||
if (latLngs.length < 2) continue
|
||||
|
||||
allLatLngs.push(...latLngs)
|
||||
const color = getTrackColor(segment.colorIndex)
|
||||
|
||||
L.polyline(latLngs, {
|
||||
color,
|
||||
weight: LINE_WEIGHT,
|
||||
opacity: LINE_OPACITY,
|
||||
lineCap: 'round',
|
||||
lineJoin: 'round'
|
||||
})
|
||||
.addTo(trackGroup)
|
||||
.bindPopup(t('stats.day_label', { day: segment.dayOfTravel }))
|
||||
|
||||
L.circleMarker(latLngs[0], {
|
||||
radius: 7,
|
||||
fillColor: color,
|
||||
fillOpacity: 0.95,
|
||||
color: '#ffffff',
|
||||
weight: 2
|
||||
})
|
||||
.addTo(trackGroup)
|
||||
.bindPopup(`${t('stats.day_label', { day: segment.dayOfTravel })} – ${t('logs.track_map_start')}`)
|
||||
}
|
||||
|
||||
if (allLatLngs.length > 0) {
|
||||
pendingFrames.push(
|
||||
requestAnimationFrame(() => {
|
||||
if (cancelled) return
|
||||
map.invalidateSize({ animate: false })
|
||||
pendingFrames.push(
|
||||
requestAnimationFrame(() => {
|
||||
if (cancelled) return
|
||||
try {
|
||||
const bounds = L.latLngBounds(allLatLngs.map(([lat, lng]) => L.latLng(lat, lng)))
|
||||
if (bounds.isValid()) {
|
||||
map.fitBounds(bounds, { padding: [24, 24], maxZoom: 12, animate: false })
|
||||
}
|
||||
} catch {
|
||||
map.setView(allLatLngs[0], 11, { animate: false })
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
pendingFrames.forEach((id) => cancelAnimationFrame(id))
|
||||
map.remove()
|
||||
}
|
||||
}, [segmentsKey, segments, t])
|
||||
|
||||
if (segments.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="track-map-wrapper">
|
||||
<div
|
||||
className="track-map-container stats-multi-track-map"
|
||||
ref={containerRef}
|
||||
aria-label={t('stats.route_map_title')}
|
||||
/>
|
||||
<div className="stats-track-legend" aria-hidden="true">
|
||||
{segments.map((seg) => (
|
||||
<span key={seg.entryId} className="stats-track-legend-item">
|
||||
<span
|
||||
className="stats-track-legend-swatch"
|
||||
style={{ backgroundColor: getTrackColor(seg.colorIndex) }}
|
||||
/>
|
||||
{t('stats.day_label', { day: seg.dayOfTravel })}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
class MultiTrackMapErrorBoundary extends Component<
|
||||
{ children: ReactNode; fallback: ReactNode },
|
||||
{ hasError: boolean }
|
||||
> {
|
||||
state = { hasError: false }
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error('MultiTrackMap render failed:', error, info)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) return this.props.fallback
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
export default function MultiTrackMap(props: MultiTrackMapProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<MultiTrackMapErrorBoundary
|
||||
fallback={<div className="track-error-msg">{t('logs.track_map_error')}</div>}
|
||||
>
|
||||
<MultiTrackMapInner {...props} />
|
||||
</MultiTrackMapErrorBoundary>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 { useLiveQuery } from 'dexie-react-hooks'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import { Camera, Trash2 } from 'lucide-react'
|
||||
@@ -159,7 +160,8 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
|
||||
|
||||
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)
|
||||
@@ -231,7 +233,6 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Bell, BellOff } from 'lucide-react'
|
||||
import {
|
||||
disableCollaboratorChangePush,
|
||||
enableCollaboratorChangePush,
|
||||
fetchPushPrefs,
|
||||
getNotificationPermission,
|
||||
isPushSupported
|
||||
} from '../services/pushNotifications.js'
|
||||
import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
|
||||
export default function PushNotificationSettings() {
|
||||
const { t } = useTranslation()
|
||||
const { showAlert } = useDialog()
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [toggling, setToggling] = useState(false)
|
||||
|
||||
const supported = isPushSupported()
|
||||
const permission = getNotificationPermission()
|
||||
const iosNeedsInstall = isIosDevice() && !isRunningStandalone()
|
||||
|
||||
const loadPrefs = useCallback(async () => {
|
||||
if (!supported) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const prefs = await fetchPushPrefs()
|
||||
setEnabled(prefs.collaboratorChangesEnabled)
|
||||
} catch (err) {
|
||||
console.error('Failed to load push prefs:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [supported])
|
||||
|
||||
useEffect(() => {
|
||||
void loadPrefs()
|
||||
}, [loadPrefs])
|
||||
|
||||
const handleToggle = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const next = e.target.checked
|
||||
setToggling(true)
|
||||
try {
|
||||
if (next) {
|
||||
await enableCollaboratorChangePush()
|
||||
setEnabled(true)
|
||||
trackPlausibleEvent(PlausibleEvents.PUSH_ENABLED)
|
||||
} else {
|
||||
await disableCollaboratorChangePush()
|
||||
setEnabled(false)
|
||||
trackPlausibleEvent(PlausibleEvents.PUSH_DISABLED)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : t('profile.push_error')
|
||||
showAlert(message)
|
||||
void loadPrefs()
|
||||
} finally {
|
||||
setToggling(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!supported) {
|
||||
return (
|
||||
<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('profile.push_title')}</h3>
|
||||
</div>
|
||||
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: 0 }}>
|
||||
{t('profile.push_unsupported')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="member-editor-card glass mt-4">
|
||||
<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('profile.push_title')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: '0 0 16px 0' }}>
|
||||
{t('profile.push_desc')}
|
||||
</p>
|
||||
|
||||
{iosNeedsInstall && (
|
||||
<p className="text-muted" style={{ fontSize: '13px', margin: '0 0 12px 0' }}>
|
||||
{t('profile.push_ios_install_hint')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{permission === 'denied' && (
|
||||
<p style={{ fontSize: '13px', color: '#f87171', margin: '0 0 12px 0' }}>
|
||||
{t('profile.push_denied_hint')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<label
|
||||
className="switch-label"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
cursor: loading || toggling || iosNeedsInstall ? 'not-allowed' : 'pointer',
|
||||
fontSize: '14px',
|
||||
color: '#f1f5f9',
|
||||
opacity: loading || iosNeedsInstall ? 0.6 : 1
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={handleToggle}
|
||||
disabled={loading || toggling || iosNeedsInstall}
|
||||
style={{ width: '18px', height: '18px', cursor: 'inherit' }}
|
||||
/>
|
||||
<span>{t('profile.push_enable')}</span>
|
||||
</label>
|
||||
|
||||
{enabled && permission === 'granted' && (
|
||||
<p className="text-muted" style={{ fontSize: '12px', margin: '12px 0 0 0' }}>
|
||||
{t('profile.push_active')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,11 +5,10 @@ import { usePwaUpdate } from '../hooks/usePwaUpdate.js'
|
||||
|
||||
export default function PwaUpdatePrompt() {
|
||||
const { t } = useTranslation()
|
||||
const { needRefresh, updateApp } = usePwaUpdate()
|
||||
const { needRefresh, updateApp, dismissUpdate } = usePwaUpdate()
|
||||
const [updating, setUpdating] = useState(false)
|
||||
const [dismissed, setDismissed] = useState(false)
|
||||
|
||||
if (!needRefresh || dismissed) return null
|
||||
if (!needRefresh) return null
|
||||
|
||||
const handleUpdate = async () => {
|
||||
setUpdating(true)
|
||||
@@ -43,7 +42,7 @@ export default function PwaUpdatePrompt() {
|
||||
<button
|
||||
type="button"
|
||||
className="pwa-update-link"
|
||||
onClick={() => setDismissed(true)}
|
||||
onClick={dismissUpdate}
|
||||
>
|
||||
{t('pwa.later')}
|
||||
</button>
|
||||
@@ -52,7 +51,7 @@ export default function PwaUpdatePrompt() {
|
||||
<button
|
||||
type="button"
|
||||
className="pwa-update-close"
|
||||
onClick={() => setDismissed(true)}
|
||||
onClick={dismissUpdate}
|
||||
aria-label={t('pwa.later')}
|
||||
>
|
||||
<X size={18} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { decryptJson } from '../services/crypto.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import VesselForm from './VesselForm.tsx'
|
||||
import CrewForm from './CrewForm.tsx'
|
||||
import LogEntriesList from './LogEntriesList.tsx'
|
||||
@@ -124,6 +125,7 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
||||
}
|
||||
}
|
||||
setGpsTracks(decGpsTracks)
|
||||
trackPlausibleEvent(PlausibleEvents.PUBLIC_LINK_OPENED)
|
||||
|
||||
} catch (err: any) {
|
||||
console.error(err)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ScrollText, X } from 'lucide-react'
|
||||
|
||||
export type DisclaimerVariant = 'accept' | 'view'
|
||||
|
||||
interface RegistrationDisclaimerProps {
|
||||
onDismiss: () => void
|
||||
variant?: DisclaimerVariant
|
||||
}
|
||||
|
||||
export default function RegistrationDisclaimer({
|
||||
onDismiss,
|
||||
variant = 'accept'
|
||||
}: RegistrationDisclaimerProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const sections = [
|
||||
{ title: t('disclaimer.e2e_title'), body: t('disclaimer.e2e_body') },
|
||||
{ title: t('disclaimer.pwa_title'), body: t('disclaimer.pwa_body') },
|
||||
{ title: t('disclaimer.storage_title'), body: t('disclaimer.storage_body') },
|
||||
{ title: t('disclaimer.free_title'), body: t('disclaimer.free_body') },
|
||||
{ title: t('disclaimer.liability_title'), body: t('disclaimer.liability_body') },
|
||||
{ title: t('disclaimer.warranty_title'), body: t('disclaimer.warranty_body') }
|
||||
]
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`auth-card glass registration-disclaimer${variant === 'view' ? ' registration-disclaimer--modal' : ''}`}
|
||||
role="document"
|
||||
>
|
||||
<div className="auth-header">
|
||||
<ScrollText className="auth-icon accent" size={48} />
|
||||
<h2>{t('disclaimer.title')}</h2>
|
||||
{variant === 'view' && (
|
||||
<button
|
||||
type="button"
|
||||
className="registration-disclaimer__close"
|
||||
onClick={onDismiss}
|
||||
aria-label={t('disclaimer.close')}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="registration-disclaimer__intro">{t('disclaimer.intro')}</p>
|
||||
|
||||
<div className="registration-disclaimer__sections">
|
||||
{sections.map((section) => (
|
||||
<section key={section.title} className="registration-disclaimer__section">
|
||||
<h3>{section.title}</h3>
|
||||
<p>{section.body}</p>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="registration-disclaimer__copyright">{t('disclaimer.copyright')}</p>
|
||||
|
||||
<div className="auth-actions">
|
||||
<button type="button" className="btn primary" onClick={onDismiss}>
|
||||
{variant === 'accept' ? t('disclaimer.accept') : t('disclaimer.close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Settings as SettingsIcon, Save, Check, Users, Trash2, Copy, Link as LinkIcon } from 'lucide-react'
|
||||
import { Settings as SettingsIcon, Check, Users, Trash2, Copy, Link as LinkIcon } from 'lucide-react'
|
||||
import { ensureLogbookKey } from '../services/logbookKeys.js'
|
||||
import AccountDangerZone from './AccountDangerZone.tsx'
|
||||
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
|
||||
import LogbookBackupPanel from './LogbookBackupPanel.tsx'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import { notifyAppearanceChanged } from '../services/appearance.js'
|
||||
import ThemedSelect from './ThemedSelect.tsx'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { apiFetch } from '../services/api.js'
|
||||
|
||||
interface SettingsFormProps {
|
||||
logbookId?: string | null
|
||||
onLogbookRestored?: (logbookId: string, title: string) => void
|
||||
}
|
||||
|
||||
interface Collaborator {
|
||||
@@ -20,23 +20,16 @@ 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'))
|
||||
.join('')
|
||||
}
|
||||
|
||||
export default function SettingsForm({ logbookId }: SettingsFormProps) {
|
||||
export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const { showConfirm, showAlert } = useDialog()
|
||||
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('')
|
||||
@@ -45,7 +38,6 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
|
||||
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)
|
||||
@@ -61,15 +53,10 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
|
||||
const loadShareLink = async () => {
|
||||
if (!logbookId) return
|
||||
setLoadingShareLink(true)
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId) return
|
||||
if (!localStorage.getItem('active_userid')) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/collaboration/share-link?logbookId=${logbookId}`, {
|
||||
headers: {
|
||||
'X-User-Id': userId
|
||||
}
|
||||
})
|
||||
const res = await apiFetch(`/api/collaboration/share-link?logbookId=${logbookId}`)
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
@@ -93,17 +80,12 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
|
||||
const handleToggleShare = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!logbookId) return
|
||||
const checked = e.target.checked
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId) return
|
||||
if (!localStorage.getItem('active_userid')) return
|
||||
|
||||
setLoadingShareLink(true)
|
||||
try {
|
||||
const res = await fetch('/api/collaboration/share-link', {
|
||||
const res = await apiFetch('/api/collaboration/share-link', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-User-Id': userId
|
||||
},
|
||||
body: JSON.stringify({ logbookId, enabled: checked })
|
||||
})
|
||||
|
||||
@@ -114,6 +96,7 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
|
||||
const logbookKey = await ensureLogbookKey(logbookId)
|
||||
const hexKey = bufferToHex(logbookKey)
|
||||
setShareLink(`${window.location.origin}/share?token=${data.token}#key=${hexKey}`)
|
||||
trackPlausibleEvent(PlausibleEvents.LOGBOOK_SHARED)
|
||||
showAlert('Public share link enabled!')
|
||||
} else {
|
||||
setShareEnabled(false)
|
||||
@@ -123,9 +106,9 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
|
||||
} 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)
|
||||
}
|
||||
@@ -139,19 +122,13 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const loadCollaborators = async () => {
|
||||
setLoadingCollabs(true)
|
||||
setCollabError(null)
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId) return
|
||||
if (!localStorage.getItem('active_userid')) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/collaboration/collaborators?logbookId=${logbookId}`, {
|
||||
headers: {
|
||||
'X-User-Id': userId
|
||||
}
|
||||
})
|
||||
const res = await apiFetch(`/api/collaboration/collaborators?logbookId=${logbookId}`)
|
||||
|
||||
if (res.status === 403) {
|
||||
setIsOwner(false)
|
||||
@@ -178,20 +155,13 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
|
||||
if (!logbookId) return
|
||||
setGeneratingInvite(true)
|
||||
setInviteLink('')
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId) return
|
||||
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 fetch('/api/collaboration/invite', {
|
||||
const res = await apiFetch('/api/collaboration/invite', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-User-Id': userId
|
||||
},
|
||||
body: JSON.stringify({ logbookId, role: 'WRITE' })
|
||||
})
|
||||
|
||||
@@ -200,15 +170,14 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
|
||||
}
|
||||
|
||||
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)
|
||||
} catch (err: any) {
|
||||
trackPlausibleEvent(PlausibleEvents.INVITE_GENERATED)
|
||||
} 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)
|
||||
}
|
||||
@@ -223,16 +192,12 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
|
||||
}
|
||||
|
||||
const handleRevoke = async (collabId: string, collName: string) => {
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId) return
|
||||
if (!localStorage.getItem('active_userid')) return
|
||||
|
||||
if (await showConfirm(t('logs.revoke_confirm'), collName, t('logs.confirm_yes'), t('logs.confirm_no'))) {
|
||||
try {
|
||||
const res = await fetch(`/api/collaboration/collaborators/${collabId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-User-Id': userId
|
||||
}
|
||||
const res = await apiFetch(`/api/collaboration/collaborators/${collabId}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
@@ -241,40 +206,26 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
|
||||
} 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 (
|
||||
@@ -283,106 +234,12 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
|
||||
<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" />
|
||||
|
||||
{/* 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"
|
||||
type="password"
|
||||
className="input-text"
|
||||
placeholder="e.g. 8b6a7f...d8"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</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="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' }}>
|
||||
@@ -394,6 +251,10 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
|
||||
{t('settings.share_desc')}
|
||||
</p>
|
||||
|
||||
<p className="signature-lock-notice" style={{ marginBottom: '16px' }}>
|
||||
{t('settings.share_privacy_warning')}
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '20px' }}>
|
||||
<label className="switch-label" style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer', fontSize: '14px', color: '#f1f5f9' }}>
|
||||
<input
|
||||
@@ -409,7 +270,7 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
|
||||
</div>
|
||||
|
||||
{shareEnabled && shareLink && (
|
||||
<div className="input-group mb-4" style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<div className="input-group mb-4 copy-link-row">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
@@ -431,7 +292,10 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Crew Collaboration Card (Only visible to Logbook Owner) */}
|
||||
{logbookId && isOwner && (
|
||||
<LogbookBackupPanel logbookId={logbookId} onRestored={onLogbookRestored} />
|
||||
)}
|
||||
|
||||
{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' }}>
|
||||
@@ -445,7 +309,7 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
|
||||
{t('logs.invite_link_desc')}
|
||||
</p>
|
||||
|
||||
<div className="form-actions" style={{ justifyContent: 'flex-start', gap: '12px', marginBottom: '20px' }}>
|
||||
<div className="form-actions form-actions--start" style={{ gap: '12px', marginBottom: '20px' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
@@ -459,7 +323,7 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
|
||||
</div>
|
||||
|
||||
{inviteLink && (
|
||||
<div className="input-group mb-6" style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<div className="input-group mb-6 copy-link-row">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
@@ -479,7 +343,6 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collaborator List */}
|
||||
<h4 style={{ color: '#fbbf24', fontSize: '14px', marginBottom: '12px' }}>
|
||||
{t('logs.collaborators_list')}
|
||||
</h4>
|
||||
@@ -525,8 +388,6 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Danger Zone / Account Deletion */}
|
||||
<AccountDangerZone className="mt-6" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import { Check } from 'lucide-react'
|
||||
import SignaturePad from './SignaturePad.tsx'
|
||||
import PasskeySignButton from './PasskeySignButton.tsx'
|
||||
import type { PasskeySignature, SignatureValue } from '../types/signatures.js'
|
||||
import { isPasskeySignature } from '../utils/signatures.js'
|
||||
import { isPasskeySignature, getSignaturePayload, getSignatureAttribution } from '../utils/signatures.js'
|
||||
import { formatAppDateTime } from '../utils/dateTimeFormat.js'
|
||||
|
||||
type SignatureMode = 'passkey' | 'classic'
|
||||
|
||||
@@ -13,7 +14,7 @@ interface SignatureSectionProps {
|
||||
disabled?: boolean
|
||||
isOnline: boolean
|
||||
canSignSkipper: boolean
|
||||
hasWriteCollaborators: boolean
|
||||
canSignCrew: boolean
|
||||
signSkipper: SignatureValue | ''
|
||||
signCrew: SignatureValue | ''
|
||||
skipperSignatureValid: boolean
|
||||
@@ -25,14 +26,28 @@ interface SignatureSectionProps {
|
||||
onBeforeSign?: () => Promise<boolean>
|
||||
}
|
||||
|
||||
function SignerAttributionBadge({ value }: { value: SignatureValue | '' }) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const attribution = getSignatureAttribution(value)
|
||||
if (!attribution) return null
|
||||
|
||||
const formattedDate = formatAppDateTime(attribution.signedAt, i18n.language)
|
||||
|
||||
return (
|
||||
<div className="passkey-sign-badge valid signature-attribution-badge">
|
||||
<span>{t('logs.sign_passkey_signed', { username: attribution.username })}</span>
|
||||
<span className="passkey-sign-date">{formattedDate}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function padValue(value: SignatureValue | ''): string {
|
||||
if (!value || isPasskeySignature(value)) return ''
|
||||
return value
|
||||
return getSignaturePayload(value)
|
||||
}
|
||||
|
||||
function modeFromValue(value: SignatureValue | '', passkeyAvailable: boolean): SignatureMode {
|
||||
if (isPasskeySignature(value)) return 'passkey'
|
||||
if (value) return 'classic'
|
||||
if (getSignaturePayload(value)) return 'classic'
|
||||
return passkeyAvailable ? 'passkey' : 'classic'
|
||||
}
|
||||
|
||||
@@ -108,6 +123,7 @@ function RoleSignatureBlock({
|
||||
}
|
||||
return (
|
||||
<div className="signature-role-block">
|
||||
<SignerAttributionBadge value={value} />
|
||||
<SignaturePad
|
||||
id={padId}
|
||||
label={roleLabel}
|
||||
@@ -162,6 +178,7 @@ function RoleSignatureBlock({
|
||||
|
||||
{showClassicPanel && (
|
||||
<>
|
||||
<SignerAttributionBadge value={value} />
|
||||
<SignaturePad
|
||||
id={padId}
|
||||
label={roleLabel}
|
||||
@@ -189,7 +206,7 @@ export default function SignatureSection({
|
||||
disabled = false,
|
||||
isOnline,
|
||||
canSignSkipper,
|
||||
hasWriteCollaborators,
|
||||
canSignCrew,
|
||||
signSkipper,
|
||||
signCrew,
|
||||
skipperSignatureValid,
|
||||
@@ -203,7 +220,7 @@ export default function SignatureSection({
|
||||
const { t } = useTranslation()
|
||||
|
||||
const showSkipperPasskey = canSignSkipper && isOnline
|
||||
const showCrewPasskey = hasWriteCollaborators && isOnline
|
||||
const showCrewPasskey = canSignCrew && isOnline
|
||||
const hasSignature = !!(signSkipper || signCrew)
|
||||
|
||||
return (
|
||||
@@ -228,7 +245,7 @@ export default function SignatureSection({
|
||||
passkeySignature={isPasskeySignature(signSkipper) ? signSkipper : undefined}
|
||||
signatureValid={skipperSignatureValid}
|
||||
showPasskey={showSkipperPasskey}
|
||||
readOnly={readOnly}
|
||||
readOnly={readOnly || !canSignSkipper}
|
||||
disabled={disabled}
|
||||
classicHint={showSkipperPasskey ? t('logs.sign_classic_or_passkey') : undefined}
|
||||
offlineHint={!isOnline && canSignSkipper ? t('logs.sign_offline_hint') : undefined}
|
||||
@@ -245,7 +262,7 @@ export default function SignatureSection({
|
||||
passkeySignature={isPasskeySignature(signCrew) ? signCrew : undefined}
|
||||
signatureValid={crewSignatureValid}
|
||||
showPasskey={showCrewPasskey}
|
||||
readOnly={readOnly}
|
||||
readOnly={readOnly || !canSignCrew}
|
||||
disabled={disabled}
|
||||
classicHint={showCrewPasskey ? t('logs.sign_crew_passkey_hint') : undefined}
|
||||
onChange={onSignCrewChange}
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { BarChart2, Anchor, Droplets, Fuel, Sailboat, Gauge, Timer } from 'lucide-react'
|
||||
import MultiTrackMap from './MultiTrackMap.tsx'
|
||||
import {
|
||||
formatLiters,
|
||||
formatHours,
|
||||
formatNm,
|
||||
loadAccountStats,
|
||||
loadLogbookStats,
|
||||
type LogbookStatsSummary,
|
||||
type StatsTotals,
|
||||
type TravelDayStats
|
||||
} from '../services/statsAggregation.js'
|
||||
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
|
||||
import { formatFuelPerMotorHour } from '../utils/fuelStats.js'
|
||||
|
||||
interface StatsDashboardProps {
|
||||
logbookId: string
|
||||
logbookTitle: string
|
||||
}
|
||||
|
||||
type StatsScope = 'logbook' | 'account'
|
||||
|
||||
function maxBarValue(days: TravelDayStats[], pick: (d: TravelDayStats) => number): number {
|
||||
if (days.length === 0) return 1
|
||||
return Math.max(1, ...days.map(pick))
|
||||
}
|
||||
|
||||
function KpiCard({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
unit
|
||||
}: {
|
||||
icon: ReactNode
|
||||
label: string
|
||||
value: string
|
||||
unit?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="stats-kpi-card glass">
|
||||
<div className="stats-kpi-icon">{icon}</div>
|
||||
<div className="stats-kpi-body">
|
||||
<span className="stats-kpi-label">{label}</span>
|
||||
<span className="stats-kpi-value">
|
||||
{value}
|
||||
{unit ? <span className="stats-kpi-unit">{unit}</span> : null}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TotalsGrid({ totals }: { totals: StatsTotals }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="stats-kpi-grid">
|
||||
<KpiCard
|
||||
icon={<Gauge size={20} />}
|
||||
label={t('stats.total_distance')}
|
||||
value={formatNm(totals.totalDistanceNm)}
|
||||
unit={t('stats.unit_nm')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Anchor size={20} />}
|
||||
label={t('stats.travel_days')}
|
||||
value={String(totals.travelDayCount)}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Sailboat size={20} />}
|
||||
label={t('stats.sail_distance')}
|
||||
value={formatNm(totals.sailDistanceNm)}
|
||||
unit={t('stats.unit_nm')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Gauge size={20} />}
|
||||
label={t('stats.motor_distance')}
|
||||
value={formatNm(totals.motorDistanceNm)}
|
||||
unit={t('stats.unit_nm')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Timer size={20} />}
|
||||
label={t('stats.motor_hours_total')}
|
||||
value={formatHours(totals.totalMotorHours)}
|
||||
unit={t('stats.unit_h')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Fuel size={20} />}
|
||||
label={t('stats.fuel_total')}
|
||||
value={formatLiters(totals.totalFuelL)}
|
||||
unit={t('stats.unit_l')}
|
||||
/>
|
||||
{totals.fuelPerMotorHourL != null && (
|
||||
<KpiCard
|
||||
icon={<Timer size={20} />}
|
||||
label={t('stats.fuel_per_motor_hour')}
|
||||
value={formatFuelPerMotorHour(totals.fuelPerMotorHourL)}
|
||||
unit={`${t('stats.unit_l')}/${t('stats.unit_h')}`}
|
||||
/>
|
||||
)}
|
||||
<KpiCard
|
||||
icon={<Droplets size={20} />}
|
||||
label={t('stats.water_total')}
|
||||
value={formatLiters(totals.totalFreshwaterL)}
|
||||
unit={t('stats.unit_l')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DailyBarChart({
|
||||
days,
|
||||
valueFn,
|
||||
barClass,
|
||||
formatValue
|
||||
}: {
|
||||
days: TravelDayStats[]
|
||||
valueFn: (d: TravelDayStats) => number
|
||||
barClass: string
|
||||
formatValue: (v: number) => string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const max = maxBarValue(days, valueFn)
|
||||
|
||||
return (
|
||||
<div className="stats-bar-chart" role="img" aria-label={t('stats.daily_etmal')}>
|
||||
{days.map((day) => {
|
||||
const value = valueFn(day)
|
||||
const heightPct = max > 0 ? Math.max(2, (value / max) * 100) : 0
|
||||
const label = day.date
|
||||
? new Date(day.date).toLocaleDateString(undefined, { day: '2-digit', month: '2-digit' })
|
||||
: t('stats.day_label', { day: day.dayOfTravel })
|
||||
|
||||
return (
|
||||
<div key={day.entryId} className="stats-bar-column" title={`${label}: ${formatValue(value)}`}>
|
||||
<span className="stats-bar-value">{value > 0 ? formatValue(value) : ''}</span>
|
||||
<div className="stats-bar-track">
|
||||
<div className={`stats-bar ${barClass}`} style={{ height: `${heightPct}%` }} />
|
||||
</div>
|
||||
<span className="stats-bar-label">{label}</span>
|
||||
<span className="stats-bar-sublabel">{t('stats.day_label', { day: day.dayOfTravel })}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConsumptionChart({ days }: { days: TravelDayStats[] }) {
|
||||
const { t } = useTranslation()
|
||||
const max = maxBarValue(days, (d) => Math.max(d.fuelConsumptionL, d.freshwaterConsumptionL))
|
||||
|
||||
return (
|
||||
<div className="stats-bar-chart stats-consumption-chart" role="img" aria-label={t('stats.daily_consumption')}>
|
||||
{days.map((day) => {
|
||||
const fuelH = max > 0 ? Math.max(2, (day.fuelConsumptionL / max) * 100) : 0
|
||||
const waterH = max > 0 ? Math.max(2, (day.freshwaterConsumptionL / max) * 100) : 0
|
||||
const label = day.date
|
||||
? new Date(day.date).toLocaleDateString(undefined, { day: '2-digit', month: '2-digit' })
|
||||
: t('stats.day_label', { day: day.dayOfTravel })
|
||||
|
||||
return (
|
||||
<div key={day.entryId} className="stats-bar-column stats-bar-column--grouped">
|
||||
<div className="stats-bar-group">
|
||||
<div className="stats-bar-track stats-bar-track--short">
|
||||
<div className="stats-bar stats-bar--fuel" style={{ height: `${fuelH}%` }} />
|
||||
</div>
|
||||
<div className="stats-bar-track stats-bar-track--short">
|
||||
<div className="stats-bar stats-bar--water" style={{ height: `${waterH}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<span className="stats-bar-label">{label}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="stats-consumption-legend">
|
||||
<span><span className="stats-legend-swatch stats-bar--fuel" /> {t('stats.fuel_legend')}</span>
|
||||
<span><span className="stats-legend-swatch stats-bar--water" /> {t('stats.water_legend')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PropulsionBreakdown({ totals }: { totals: StatsTotals }) {
|
||||
const { t } = useTranslation()
|
||||
const total = totals.sailDistanceNm + totals.motorDistanceNm + totals.unknownPropulsionNm
|
||||
if (total <= 0) return null
|
||||
|
||||
const sailPct = (totals.sailDistanceNm / total) * 100
|
||||
const motorPct = (totals.motorDistanceNm / total) * 100
|
||||
const unknownPct = (totals.unknownPropulsionNm / total) * 100
|
||||
|
||||
return (
|
||||
<div className="stats-propulsion">
|
||||
<div className="stats-propulsion-bar" role="img" aria-label={t('stats.propulsion_title')}>
|
||||
{totals.sailDistanceNm > 0 && (
|
||||
<div className="stats-propulsion-segment stats-propulsion-segment--sail" style={{ width: `${sailPct}%` }} />
|
||||
)}
|
||||
{totals.motorDistanceNm > 0 && (
|
||||
<div className="stats-propulsion-segment stats-propulsion-segment--motor" style={{ width: `${motorPct}%` }} />
|
||||
)}
|
||||
{totals.unknownPropulsionNm > 0 && (
|
||||
<div className="stats-propulsion-segment stats-propulsion-segment--unknown" style={{ width: `${unknownPct}%` }} />
|
||||
)}
|
||||
</div>
|
||||
<div className="stats-propulsion-labels">
|
||||
<span>{t('stats.sail_distance')}: {formatNm(totals.sailDistanceNm)} {t('stats.unit_nm')} ({sailPct.toFixed(0)}%)</span>
|
||||
<span>{t('stats.motor_distance')}: {formatNm(totals.motorDistanceNm)} {t('stats.unit_nm')} ({motorPct.toFixed(0)}%)</span>
|
||||
{totals.unknownPropulsionNm > 0 && (
|
||||
<span>{t('stats.unknown_propulsion')}: {formatNm(totals.unknownPropulsionNm)} {t('stats.unit_nm')}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="stats-hint">{t('stats.propulsion_hint')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LogbookScopeView({ summary }: { summary: LogbookStatsSummary }) {
|
||||
const { t } = useTranslation()
|
||||
const { travelDays, routePorts, trackSegments, totals } = summary
|
||||
|
||||
if (travelDays.length === 0) {
|
||||
return <div className="dashboard-status-msg">{t('stats.no_data')}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TotalsGrid totals={totals} />
|
||||
|
||||
{routePorts.length > 0 && (
|
||||
<div className="member-editor-card glass mt-6">
|
||||
<h3 className="stats-section-title">{t('stats.route_overview')}</h3>
|
||||
<p className="stats-route-chain">
|
||||
{routePorts.map((port, idx) => (
|
||||
<span key={`${port}-${idx}`}>
|
||||
{idx > 0 && <span className="stats-route-arrow"> → </span>}
|
||||
{port}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{trackSegments.length > 0 && (
|
||||
<div className="member-editor-card glass mt-6">
|
||||
<h3 className="stats-section-title">{t('stats.route_map_title')}</h3>
|
||||
<MultiTrackMap segments={trackSegments} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="member-editor-card glass mt-6">
|
||||
<h3 className="stats-section-title">{t('stats.daily_etmal')}</h3>
|
||||
<p className="stats-section-sub">
|
||||
{t('stats.avg_distance')}: {formatNm(totals.avgDistancePerDayNm)} {t('stats.unit_nm')}
|
||||
</p>
|
||||
<DailyBarChart
|
||||
days={travelDays}
|
||||
valueFn={(d) => d.distanceNm}
|
||||
barClass="stats-bar--distance"
|
||||
formatValue={formatNm}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="member-editor-card glass mt-6">
|
||||
<h3 className="stats-section-title">{t('stats.daily_motor_hours')}</h3>
|
||||
<p className="stats-section-sub">
|
||||
{t('stats.avg_motor_hours')}: {formatHours(totals.avgMotorHoursPerDay)} {t('stats.unit_h')}
|
||||
{totals.fuelPerMotorHourL != null && (
|
||||
<>
|
||||
{' · '}
|
||||
{t('stats.fuel_per_motor_hour')}: {formatFuelPerMotorHour(totals.fuelPerMotorHourL)} {t('stats.unit_l')}/{t('stats.unit_h')}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<DailyBarChart
|
||||
days={travelDays}
|
||||
valueFn={(d) => d.motorHours}
|
||||
barClass="stats-bar--motor-hours"
|
||||
formatValue={formatHours}
|
||||
/>
|
||||
{travelDays.some((d) => d.fuelPerMotorHourL != null) && (
|
||||
<>
|
||||
<h4 className="stats-section-subtitle mt-4">{t('stats.daily_fuel_per_motor_hour')}</h4>
|
||||
<DailyBarChart
|
||||
days={travelDays}
|
||||
valueFn={(d) => d.fuelPerMotorHourL ?? 0}
|
||||
barClass="stats-bar--fuel-per-hour"
|
||||
formatValue={(v) => formatFuelPerMotorHour(v > 0 ? v : null)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="member-editor-card glass mt-6">
|
||||
<h3 className="stats-section-title">{t('stats.daily_consumption')}</h3>
|
||||
<p className="stats-section-sub">
|
||||
{t('stats.avg_fuel')}: {formatLiters(totals.avgFuelPerDayL)} {t('stats.unit_l')}
|
||||
{' · '}
|
||||
{t('stats.avg_water')}: {formatLiters(totals.avgFreshwaterPerDayL)} {t('stats.unit_l')}
|
||||
{totals.fuelPerNmL != null && (
|
||||
<> · {t('stats.fuel_per_nm')}: {totals.fuelPerNmL} {t('stats.unit_l')}/{t('stats.unit_nm')}</>
|
||||
)}
|
||||
{totals.fuelPerMotorHourL != null && (
|
||||
<> · {t('stats.fuel_per_motor_hour')}: {formatFuelPerMotorHour(totals.fuelPerMotorHourL)} {t('stats.unit_l')}/{t('stats.unit_h')}</>
|
||||
)}
|
||||
</p>
|
||||
<ConsumptionChart days={travelDays} />
|
||||
</div>
|
||||
|
||||
<div className="member-editor-card glass mt-6">
|
||||
<h3 className="stats-section-title">{t('stats.propulsion_title')}</h3>
|
||||
<PropulsionBreakdown totals={totals} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboardProps) {
|
||||
const { t } = useTranslation()
|
||||
const [scope, setScope] = useState<StatsScope>('logbook')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [logbookStats, setLogbookStats] = useState<LogbookStatsSummary | 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([
|
||||
loadLogbookStats(logbookId, logbookTitle, true),
|
||||
loadAccountStats(false)
|
||||
])
|
||||
setLogbookStats(lb)
|
||||
setAccountStats(acc)
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to load statistics:', err)
|
||||
setError(err instanceof Error ? err.message : 'Failed to load statistics.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [logbookId, logbookTitle])
|
||||
|
||||
useEffect(() => {
|
||||
void loadData()
|
||||
}, [loadData])
|
||||
|
||||
const accountLogbooksWithDays = useMemo(
|
||||
() => accountStats?.logbooks.filter((lb) => lb.travelDays.length > 0) ?? [],
|
||||
[accountStats]
|
||||
)
|
||||
|
||||
const allAccountDays = useMemo(() => {
|
||||
if (!accountStats) return []
|
||||
const days = accountStats.logbooks.flatMap((lb) => lb.travelDays)
|
||||
return [...days].sort(compareTravelDaysChronological)
|
||||
}, [accountStats])
|
||||
|
||||
return (
|
||||
<div className="form-card" data-tour="stats-dashboard">
|
||||
<div className="form-header">
|
||||
<BarChart2 size={24} className="form-icon" />
|
||||
<div>
|
||||
<h2>{t('stats.title')}</h2>
|
||||
<p className="stats-subtitle">{t('stats.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stats-scope-toggle" role="tablist" aria-label={t('stats.scope_label')}>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={scope === 'logbook'}
|
||||
className={`btn ${scope === 'logbook' ? 'primary' : 'secondary'}`}
|
||||
onClick={() => setScope('logbook')}
|
||||
>
|
||||
{t('stats.scope_logbook')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={scope === 'account'}
|
||||
className={`btn ${scope === 'account' ? 'primary' : 'secondary'}`}
|
||||
onClick={() => setScope('account')}
|
||||
>
|
||||
{t('stats.scope_account')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="auth-error mt-4">{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<div className="tab-placeholder mt-6">
|
||||
<BarChart2 className="header-logo spin" size={48} />
|
||||
<p>{t('stats.loading')}</p>
|
||||
</div>
|
||||
) : scope === 'logbook' && logbookStats ? (
|
||||
<LogbookScopeView summary={logbookStats} />
|
||||
) : scope === 'account' && accountStats ? (
|
||||
<>
|
||||
<TotalsGrid totals={accountStats.totals} />
|
||||
|
||||
{accountLogbooksWithDays.length === 0 ? (
|
||||
<div className="dashboard-status-msg mt-6">{t('stats.no_data')}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="member-editor-card glass mt-6">
|
||||
<h3 className="stats-section-title">{t('stats.account_logbooks')}</h3>
|
||||
<div className="stats-account-table-wrap">
|
||||
<table className="stats-account-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('stats.col_logbook')}</th>
|
||||
<th>{t('stats.travel_days')}</th>
|
||||
<th>{t('stats.total_distance')}</th>
|
||||
<th>{t('stats.fuel_total')}</th>
|
||||
<th>{t('stats.motor_hours_total')}</th>
|
||||
<th>{t('stats.water_total')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{accountLogbooksWithDays.map((lb) => (
|
||||
<tr key={lb.logbookId}>
|
||||
<td>{lb.title}</td>
|
||||
<td>{lb.totals.travelDayCount}</td>
|
||||
<td>{formatNm(lb.totals.totalDistanceNm)} {t('stats.unit_nm')}</td>
|
||||
<td>{formatLiters(lb.totals.totalFuelL)} {t('stats.unit_l')}</td>
|
||||
<td>{formatHours(lb.totals.totalMotorHours)} {t('stats.unit_h')}</td>
|
||||
<td>{formatLiters(lb.totals.totalFreshwaterL)} {t('stats.unit_l')}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{accountStats.totals.travelDayCount > 0 && (
|
||||
<>
|
||||
<div className="member-editor-card glass mt-6">
|
||||
<h3 className="stats-section-title">{t('stats.daily_etmal')}</h3>
|
||||
<DailyBarChart
|
||||
days={allAccountDays}
|
||||
valueFn={(d) => d.distanceNm}
|
||||
barClass="stats-bar--distance"
|
||||
formatValue={formatNm}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="member-editor-card glass mt-6">
|
||||
<h3 className="stats-section-title">{t('stats.daily_motor_hours')}</h3>
|
||||
{accountStats.totals.fuelPerMotorHourL != null && (
|
||||
<p className="stats-section-sub">
|
||||
{t('stats.fuel_per_motor_hour')}: {formatFuelPerMotorHour(accountStats.totals.fuelPerMotorHourL)} {t('stats.unit_l')}/{t('stats.unit_h')}
|
||||
</p>
|
||||
)}
|
||||
<DailyBarChart
|
||||
days={allAccountDays}
|
||||
valueFn={(d) => d.motorHours}
|
||||
barClass="stats-bar--motor-hours"
|
||||
formatValue={formatHours}
|
||||
/>
|
||||
{allAccountDays.some((d) => d.fuelPerMotorHourL != null) && (
|
||||
<>
|
||||
<h4 className="stats-section-subtitle mt-4">{t('stats.daily_fuel_per_motor_hour')}</h4>
|
||||
<DailyBarChart
|
||||
days={allAccountDays}
|
||||
valueFn={(d) => d.fuelPerMotorHourL ?? 0}
|
||||
barClass="stats-bar--fuel-per-hour"
|
||||
formatValue={(v) => formatFuelPerMotorHour(v > 0 ? v : null)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="member-editor-card glass mt-6">
|
||||
<h3 className="stats-section-title">{t('stats.daily_consumption')}</h3>
|
||||
{accountStats.totals.fuelPerMotorHourL != null && (
|
||||
<p className="stats-section-sub">
|
||||
{t('stats.fuel_per_motor_hour')}: {formatFuelPerMotorHour(accountStats.totals.fuelPerMotorHourL)} {t('stats.unit_l')}/{t('stats.unit_h')}
|
||||
</p>
|
||||
)}
|
||||
<ConsumptionChart days={allAccountDays} />
|
||||
</div>
|
||||
|
||||
<div className="member-editor-card glass mt-6">
|
||||
<h3 className="stats-section-title">{t('stats.propulsion_title')}</h3>
|
||||
<PropulsionBreakdown totals={accountStats.totals} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,785 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import {
|
||||
User,
|
||||
ChevronLeft,
|
||||
LogOut,
|
||||
KeyRound,
|
||||
Copy,
|
||||
Check,
|
||||
Plus,
|
||||
Trash2,
|
||||
BookOpen,
|
||||
Anchor,
|
||||
Gauge,
|
||||
Sailboat,
|
||||
Timer,
|
||||
Share2,
|
||||
Calendar,
|
||||
Lock,
|
||||
BarChart2,
|
||||
Shield,
|
||||
Smartphone,
|
||||
RefreshCw,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
CircleCheck,
|
||||
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 {
|
||||
addPasskey,
|
||||
fetchUserProfile,
|
||||
forgetUsername,
|
||||
getActiveMasterKey,
|
||||
getKnownUsernames,
|
||||
hasLocalPin,
|
||||
removeLocalPin,
|
||||
removePasskey,
|
||||
renamePasskey,
|
||||
rotateRecoveryPhrase,
|
||||
setLocalPin,
|
||||
type UserProfile
|
||||
} from '../services/auth.js'
|
||||
import {
|
||||
formatHours,
|
||||
formatNm,
|
||||
loadAccountStats,
|
||||
type AccountStatsSummary
|
||||
} from '../services/statsAggregation.js'
|
||||
import { db } from '../services/db.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
|
||||
interface UserProfilePageProps {
|
||||
onBack: () => void
|
||||
onLogout: () => void
|
||||
}
|
||||
|
||||
function formatAccountAge(createdAt: string, locale: string): string {
|
||||
const created = new Date(createdAt)
|
||||
if (Number.isNaN(created.getTime())) return createdAt
|
||||
return created.toLocaleDateString(locale, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
function KpiCard({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
unit
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
value: string
|
||||
unit?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="stats-kpi-card glass">
|
||||
<div className="stats-kpi-icon">{icon}</div>
|
||||
<div className="stats-kpi-body">
|
||||
<span className="stats-kpi-label">{label}</span>
|
||||
<span className="stats-kpi-value">
|
||||
{value}
|
||||
{unit ? <span className="stats-kpi-unit">{unit}</span> : null}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SecurityCheckItem({ ok, label }: { ok: boolean; label: string }) {
|
||||
return (
|
||||
<li className={`profile-security-item ${ok ? 'profile-security-item--ok' : 'profile-security-item--warn'}`}>
|
||||
{ok ? <CircleCheck size={18} aria-hidden="true" /> : <CircleAlert size={18} aria-hidden="true" />}
|
||||
<span>{label}</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export default function UserProfilePage({ onBack, onLogout }: UserProfilePageProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { showConfirm, showAlert } = useDialog()
|
||||
const username = localStorage.getItem('active_username') || 'Skipper'
|
||||
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null)
|
||||
const [accountStats, setAccountStats] = useState<AccountStatsSummary | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [copiedUserId, setCopiedUserId] = useState(false)
|
||||
const [passkeyBusy, setPasskeyBusy] = useState(false)
|
||||
const [pinBusy, setPinBusy] = useState(false)
|
||||
const [pinInput, setPinInput] = useState('')
|
||||
const [pinConfirm, setPinConfirm] = useState('')
|
||||
const [pinActive, setPinActive] = useState(() => hasLocalPin(username))
|
||||
const [newPasskeyLabel, setNewPasskeyLabel] = useState('')
|
||||
const [passkeyLabels, setPasskeyLabels] = useState<Record<string, string>>({})
|
||||
const [online, setOnline] = useState(navigator.onLine)
|
||||
const [isKnownDevice, setIsKnownDevice] = useState(() =>
|
||||
getKnownUsernames().some((u) => u.toLowerCase() === username.toLowerCase())
|
||||
)
|
||||
const [recoveryBusy, setRecoveryBusy] = useState(false)
|
||||
const [pendingRecoveryPhrase, setPendingRecoveryPhrase] = useState<string | null>(null)
|
||||
const [recoveryCopied, setRecoveryCopied] = useState(false)
|
||||
|
||||
const pendingSyncCount = useLiveQuery(() => db.syncQueue.count()) ?? 0
|
||||
|
||||
const sharedLogbookCount = useLiveQuery(
|
||||
() => db.logbooks.filter((lb) => lb.isShared === 1).count(),
|
||||
[]
|
||||
) ?? 0
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const profileData = await fetchUserProfile()
|
||||
setProfile(profileData)
|
||||
|
||||
try {
|
||||
const stats = await loadAccountStats(false)
|
||||
setAccountStats(stats)
|
||||
} catch (statsErr) {
|
||||
console.error('Failed to load account stats for profile:', statsErr)
|
||||
setAccountStats(null)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : t('profile.load_error'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [t])
|
||||
|
||||
useEffect(() => {
|
||||
void loadData()
|
||||
}, [loadData])
|
||||
|
||||
useEffect(() => {
|
||||
trackPlausibleEvent(PlausibleEvents.PROFILE_OPENED)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleOnline = () => setOnline(true)
|
||||
const handleOffline = () => setOnline(false)
|
||||
window.addEventListener('online', handleOnline)
|
||||
window.addEventListener('offline', handleOffline)
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline)
|
||||
window.removeEventListener('offline', handleOffline)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!profile) return
|
||||
const labels: Record<string, string> = {}
|
||||
for (const cred of profile.credentials) {
|
||||
labels[cred.id] = cred.label ?? ''
|
||||
}
|
||||
setPasskeyLabels(labels)
|
||||
}, [profile])
|
||||
|
||||
const statsTotals = accountStats?.totals
|
||||
const logbookCount =
|
||||
accountStats?.logbooks.length ?? profile?.serverMeta.ownedLogbookCount ?? 0
|
||||
|
||||
const accountAgeLabel = useMemo(() => {
|
||||
if (!profile?.createdAt) return '—'
|
||||
return formatAccountAge(profile.createdAt, i18n.language)
|
||||
}, [profile?.createdAt, i18n.language])
|
||||
|
||||
const handleCopyUserId = async () => {
|
||||
if (!profile?.userId) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(profile.userId)
|
||||
setCopiedUserId(true)
|
||||
window.setTimeout(() => setCopiedUserId(false), 2000)
|
||||
} catch {
|
||||
showAlert(t('profile.copy_failed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddPasskey = async () => {
|
||||
setPasskeyBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const hadLabel = Boolean(newPasskeyLabel.trim())
|
||||
await addPasskey(newPasskeyLabel)
|
||||
setNewPasskeyLabel('')
|
||||
await loadData()
|
||||
trackPlausibleEvent(PlausibleEvents.PASSKEY_ADDED, { labeled: hadLabel })
|
||||
showAlert(t('profile.add_passkey_success'))
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : t('profile.add_passkey_failed'))
|
||||
} finally {
|
||||
setPasskeyBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRenamePasskey = async (credentialId: string) => {
|
||||
setPasskeyBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
await renamePasskey(credentialId, passkeyLabels[credentialId] ?? '')
|
||||
await loadData()
|
||||
trackPlausibleEvent(PlausibleEvents.PASSKEY_RENAMED)
|
||||
showAlert(t('profile.passkey_rename_success'))
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : t('profile.passkey_rename_failed'))
|
||||
} finally {
|
||||
setPasskeyBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleForgetDevice = async () => {
|
||||
const confirmed = await showConfirm(
|
||||
t('profile.device_forget_confirm_desc'),
|
||||
t('profile.device_forget_confirm_title'),
|
||||
t('profile.device_forget_confirm_yes'),
|
||||
t('profile.device_forget_confirm_no')
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
forgetUsername(username)
|
||||
setIsKnownDevice(false)
|
||||
trackPlausibleEvent(PlausibleEvents.DEVICE_FORGOTTEN)
|
||||
}
|
||||
|
||||
const handleRemovePasskey = async (credentialId: string) => {
|
||||
if (profile && profile.credentials.length <= 1) {
|
||||
trackPlausibleEvent(PlausibleEvents.LAST_PASSKEY_REMOVE_HINTED)
|
||||
await showAlert(
|
||||
t('profile.remove_passkey_last_desc'),
|
||||
t('profile.remove_passkey_last_title')
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const confirmed = await showConfirm(
|
||||
t('profile.remove_passkey_confirm_desc'),
|
||||
t('profile.remove_passkey_confirm_title'),
|
||||
t('profile.remove_passkey_confirm_yes'),
|
||||
t('profile.remove_passkey_confirm_no')
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
setPasskeyBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
await removePasskey(credentialId)
|
||||
await loadData()
|
||||
trackPlausibleEvent(PlausibleEvents.PASSKEY_REMOVED)
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : t('profile.remove_passkey_failed'))
|
||||
} finally {
|
||||
setPasskeyBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSavePin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (pinInput.length < 4) {
|
||||
setError(t('profile.pin_length_error'))
|
||||
return
|
||||
}
|
||||
if (pinInput !== pinConfirm) {
|
||||
setError(t('profile.pin_mismatch'))
|
||||
return
|
||||
}
|
||||
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) {
|
||||
setError(t('profile.pin_no_session'))
|
||||
return
|
||||
}
|
||||
|
||||
const pinAction = pinActive ? 'change' : 'set'
|
||||
|
||||
setPinBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
await setLocalPin(pinInput.trim(), username, masterKey)
|
||||
setPinActive(true)
|
||||
setPinInput('')
|
||||
setPinConfirm('')
|
||||
trackPlausibleEvent(PlausibleEvents.LOCAL_PIN_SET, { action: pinAction })
|
||||
showAlert(t('profile.pin_saved'))
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : t('profile.pin_save_failed'))
|
||||
} finally {
|
||||
setPinBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemovePin = async () => {
|
||||
const confirmed = await showConfirm(
|
||||
t('profile.remove_pin_confirm_desc'),
|
||||
t('profile.remove_pin_confirm_title'),
|
||||
t('profile.remove_pin_confirm_yes'),
|
||||
t('profile.remove_pin_confirm_no')
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
removeLocalPin(username)
|
||||
setPinActive(false)
|
||||
setPinInput('')
|
||||
setPinConfirm('')
|
||||
trackPlausibleEvent(PlausibleEvents.LOCAL_PIN_REMOVED)
|
||||
}
|
||||
|
||||
const handleRotateRecovery = async () => {
|
||||
const confirmed = await showConfirm(
|
||||
t('profile.recovery_rotate_confirm_desc'),
|
||||
t('profile.recovery_rotate_confirm_title'),
|
||||
t('profile.recovery_rotate_confirm_yes'),
|
||||
t('profile.recovery_rotate_confirm_no')
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
if (!getActiveMasterKey()) {
|
||||
setError(t('profile.recovery_rotate_no_session'))
|
||||
return
|
||||
}
|
||||
|
||||
setRecoveryBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const phrase = await rotateRecoveryPhrase()
|
||||
setPendingRecoveryPhrase(phrase)
|
||||
trackPlausibleEvent(PlausibleEvents.RECOVERY_ROTATED)
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.message === 'NO_ACTIVE_MASTER_KEY') {
|
||||
setError(t('profile.recovery_rotate_no_session'))
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : t('profile.recovery_rotate_failed'))
|
||||
}
|
||||
} finally {
|
||||
setRecoveryBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyRecoveryPhrase = async () => {
|
||||
if (!pendingRecoveryPhrase) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(pendingRecoveryPhrase)
|
||||
setRecoveryCopied(true)
|
||||
window.setTimeout(() => setRecoveryCopied(false), 2000)
|
||||
} catch {
|
||||
showAlert(t('profile.copy_failed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirmRecoverySaved = () => {
|
||||
setPendingRecoveryPhrase(null)
|
||||
setRecoveryCopied(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dashboard-container">
|
||||
<header className="dashboard-header dashboard-header--profile">
|
||||
<div className="header-brand profile-header-brand">
|
||||
<button className="btn-back profile-back-btn" onClick={onBack} title={t('profile.back')}>
|
||||
<ChevronLeft size={16} />
|
||||
<span>{t('profile.back')}</span>
|
||||
</button>
|
||||
<div>
|
||||
<div className="header-brand-title-row">
|
||||
<h1>{t('profile.title')}</h1>
|
||||
<BetaBadge />
|
||||
</div>
|
||||
<p className="subtitle">{t('profile.subtitle', { name: username })}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="header-actions">
|
||||
<button className="btn-icon logout" onClick={onLogout} title={t('dashboard.logout')}>
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="profile-main">
|
||||
{error && <div className="auth-error mb-4">{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<div className="tab-placeholder">
|
||||
<User className="header-logo spin" size={48} />
|
||||
<p>{t('profile.loading')}</p>
|
||||
</div>
|
||||
) : pendingRecoveryPhrase ? (
|
||||
<section className="form-card profile-recovery-card">
|
||||
<div className="form-header">
|
||||
<KeyRound size={24} className="form-icon" />
|
||||
<h2>{t('auth.recovery_title')}</h2>
|
||||
</div>
|
||||
<p className="profile-recovery-warning">{t('profile.recovery_rotate_new_warning')}</p>
|
||||
<div className="phrase-grid">
|
||||
{pendingRecoveryPhrase.split(' ').map((word, idx) => (
|
||||
<div key={idx} className="phrase-word">
|
||||
<span className="word-num">{idx + 1}</span>
|
||||
{word}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="form-actions profile-recovery-actions">
|
||||
<button type="button" className="btn secondary" onClick={() => void handleCopyRecoveryPhrase()}>
|
||||
{recoveryCopied ? t('auth.copied') : t('auth.copy_phrase')}
|
||||
</button>
|
||||
<button type="button" className="btn primary" onClick={handleConfirmRecoverySaved}>
|
||||
{t('auth.confirm_recovery')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
) : profile ? (
|
||||
<>
|
||||
<section className="form-card">
|
||||
<div className="form-header">
|
||||
<User size={24} className="form-icon" />
|
||||
<h2>{t('profile.identity_title')}</h2>
|
||||
</div>
|
||||
|
||||
<dl className="profile-dl">
|
||||
<div className="profile-dl-row">
|
||||
<dt>{t('profile.username')}</dt>
|
||||
<dd>{profile.username}</dd>
|
||||
</div>
|
||||
<div className="profile-dl-row">
|
||||
<dt>{t('profile.user_id')}</dt>
|
||||
<dd className="profile-user-id">
|
||||
<code>{profile.userId}</code>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon profile-copy-btn"
|
||||
onClick={() => void handleCopyUserId()}
|
||||
title={t('profile.copy_user_id')}
|
||||
>
|
||||
{copiedUserId ? <Check size={16} /> : <Copy size={16} />}
|
||||
</button>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="profile-dl-row">
|
||||
<dt>{t('profile.account_since')}</dt>
|
||||
<dd>{accountAgeLabel}</dd>
|
||||
</div>
|
||||
<div className="profile-dl-row">
|
||||
<dt>{t('profile.prf_status')}</dt>
|
||||
<dd>
|
||||
{profile.hasPrfEncryption
|
||||
? t('profile.prf_active')
|
||||
: t('profile.prf_inactive')}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<UserProfilePreferences userId={profile.userId} />
|
||||
|
||||
<section className="member-editor-card glass">
|
||||
<div className="profile-section-header">
|
||||
<Shield size={20} />
|
||||
<h3>{t('profile.security_title')}</h3>
|
||||
</div>
|
||||
<p className="profile-section-desc">{t('profile.security_desc')}</p>
|
||||
<ul className="profile-security-list">
|
||||
<SecurityCheckItem
|
||||
ok={profile.credentials.length > 0}
|
||||
label={
|
||||
profile.credentials.length > 0
|
||||
? t('profile.security_passkeys_ok')
|
||||
: t('profile.security_passkeys_missing')
|
||||
}
|
||||
/>
|
||||
<SecurityCheckItem
|
||||
ok={profile.hasPrfEncryption}
|
||||
label={
|
||||
profile.hasPrfEncryption
|
||||
? t('profile.security_prf_ok')
|
||||
: t('profile.security_prf_missing')
|
||||
}
|
||||
/>
|
||||
<SecurityCheckItem
|
||||
ok={pinActive}
|
||||
label={pinActive ? t('profile.security_pin_ok') : t('profile.security_pin_missing')}
|
||||
/>
|
||||
<SecurityCheckItem ok label={t('profile.security_recovery_ok')} />
|
||||
</ul>
|
||||
<p className="profile-section-desc profile-recovery-hint">{t('profile.security_recovery_hint')}</p>
|
||||
<div className="form-actions profile-recovery-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => void handleRotateRecovery()}
|
||||
disabled={recoveryBusy || passkeyBusy || pinBusy}
|
||||
>
|
||||
{recoveryBusy ? t('profile.processing') : t('profile.recovery_rotate_btn')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="member-editor-card glass">
|
||||
<div className="profile-section-header">
|
||||
<Smartphone size={20} />
|
||||
<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'}`}>
|
||||
{online ? (
|
||||
pendingSyncCount > 0 ? (
|
||||
<>
|
||||
<RefreshCw size={16} className="spin" aria-hidden="true" />
|
||||
<span>{t('profile.device_sync_pending', { count: pendingSyncCount })}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Wifi size={16} aria-hidden="true" />
|
||||
<span>{t('profile.device_sync_ok')}</span>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<WifiOff size={16} aria-hidden="true" />
|
||||
<span>{t('sync.status_offline')}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="profile-pin-status">
|
||||
{isKnownDevice ? t('profile.device_remembered') : t('profile.device_not_remembered')}
|
||||
</p>
|
||||
{isKnownDevice && (
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => void handleForgetDevice()}
|
||||
>
|
||||
{t('profile.device_forget_btn')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="member-editor-card glass">
|
||||
<div className="profile-section-header">
|
||||
<Lock size={20} />
|
||||
<h3>{t('profile.pin_title')}</h3>
|
||||
</div>
|
||||
<p className="profile-section-desc">{t('auth.setup_pin_warning')}</p>
|
||||
<p className="profile-pin-status">
|
||||
{t('profile.pin_status')}:{' '}
|
||||
<strong>{pinActive ? t('profile.pin_active') : t('profile.pin_inactive')}</strong>
|
||||
</p>
|
||||
|
||||
<form onSubmit={(e) => void handleSavePin(e)} className="profile-pin-form">
|
||||
<div className="input-group">
|
||||
<label htmlFor="profile-pin">{t('auth.pin_label')}</label>
|
||||
<input
|
||||
id="profile-pin"
|
||||
type="password"
|
||||
inputMode="numeric"
|
||||
autoComplete="new-password"
|
||||
className="input-text"
|
||||
placeholder={t('auth.pin_placeholder')}
|
||||
value={pinInput}
|
||||
onChange={(e) => setPinInput(e.target.value)}
|
||||
disabled={pinBusy}
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label htmlFor="profile-pin-confirm">{t('profile.pin_confirm_label')}</label>
|
||||
<input
|
||||
id="profile-pin-confirm"
|
||||
type="password"
|
||||
inputMode="numeric"
|
||||
autoComplete="new-password"
|
||||
className="input-text"
|
||||
placeholder={t('profile.pin_confirm_placeholder')}
|
||||
value={pinConfirm}
|
||||
onChange={(e) => setPinConfirm(e.target.value)}
|
||||
disabled={pinBusy}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn primary"
|
||||
disabled={pinBusy || pinInput.length < 4 || pinConfirm.length < 4}
|
||||
>
|
||||
{pinActive ? t('profile.pin_change_btn') : t('profile.pin_set_btn')}
|
||||
</button>
|
||||
{pinActive && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => void handleRemovePin()}
|
||||
disabled={pinBusy}
|
||||
>
|
||||
{t('profile.pin_remove_btn')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="member-editor-card glass">
|
||||
<div className="profile-section-header">
|
||||
<KeyRound size={20} />
|
||||
<h3>{t('profile.passkeys_title')}</h3>
|
||||
</div>
|
||||
<p className="profile-section-desc">{t('profile.passkeys_desc')}</p>
|
||||
|
||||
{profile.credentials.length === 0 ? (
|
||||
<p className="profile-empty">{t('profile.passkeys_empty')}</p>
|
||||
) : (
|
||||
<ul className="profile-passkey-list">
|
||||
{profile.credentials.map((cred) => (
|
||||
<li key={cred.id} className="profile-passkey-item">
|
||||
<div className="profile-passkey-main">
|
||||
<span className="profile-passkey-label">
|
||||
{cred.label || t('profile.passkey_unnamed')}
|
||||
</span>
|
||||
<span className="profile-passkey-id">{cred.credentialIdPreview}</span>
|
||||
{cred.transports.length > 0 && (
|
||||
<span className="profile-passkey-transports">
|
||||
{cred.transports.join(', ')}
|
||||
</span>
|
||||
)}
|
||||
<div className="profile-passkey-rename">
|
||||
<input
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={passkeyLabels[cred.id] ?? ''}
|
||||
onChange={(e) =>
|
||||
setPasskeyLabels((prev) => ({ ...prev, [cred.id]: e.target.value }))
|
||||
}
|
||||
placeholder={t('profile.passkey_label_placeholder')}
|
||||
disabled={passkeyBusy}
|
||||
maxLength={64}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => void handleRenamePasskey(cred.id)}
|
||||
disabled={passkeyBusy}
|
||||
>
|
||||
{t('profile.passkey_rename_btn')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon danger"
|
||||
onClick={() => void handleRemovePasskey(cred.id)}
|
||||
disabled={passkeyBusy}
|
||||
title={t('profile.remove_passkey_btn')}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="profile-add-passkey">
|
||||
<div className="input-group">
|
||||
<label htmlFor="profile-new-passkey-label">{t('profile.passkey_label')}</label>
|
||||
<input
|
||||
id="profile-new-passkey-label"
|
||||
type="text"
|
||||
className="input-text"
|
||||
value={newPasskeyLabel}
|
||||
onChange={(e) => setNewPasskeyLabel(e.target.value)}
|
||||
placeholder={t('profile.passkey_label_placeholder')}
|
||||
disabled={passkeyBusy}
|
||||
maxLength={64}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-actions mt-4">
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
onClick={() => void handleAddPasskey()}
|
||||
disabled={passkeyBusy}
|
||||
>
|
||||
<Plus size={16} />
|
||||
{passkeyBusy ? t('profile.processing') : t('profile.add_passkey_btn')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="form-card">
|
||||
<div className="form-header">
|
||||
<BarChart2 size={24} className="form-icon" />
|
||||
<div>
|
||||
<h2>{t('profile.stats_title')}</h2>
|
||||
<p className="stats-subtitle">{t('profile.stats_subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(statsTotals || profile) && (
|
||||
<div className="stats-kpi-grid">
|
||||
<KpiCard
|
||||
icon={<BookOpen size={20} />}
|
||||
label={t('profile.stats_logbooks')}
|
||||
value={String(logbookCount)}
|
||||
/>
|
||||
{statsTotals && (
|
||||
<>
|
||||
<KpiCard
|
||||
icon={<Anchor size={20} />}
|
||||
label={t('stats.travel_days')}
|
||||
value={String(statsTotals.travelDayCount)}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Gauge size={20} />}
|
||||
label={t('stats.total_distance')}
|
||||
value={formatNm(statsTotals.totalDistanceNm)}
|
||||
unit={t('stats.unit_nm')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Sailboat size={20} />}
|
||||
label={t('stats.sail_distance')}
|
||||
value={formatNm(statsTotals.sailDistanceNm)}
|
||||
unit={t('stats.unit_nm')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Gauge size={20} />}
|
||||
label={t('stats.motor_distance')}
|
||||
value={formatNm(statsTotals.motorDistanceNm)}
|
||||
unit={t('stats.unit_nm')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Timer size={20} />}
|
||||
label={t('stats.motor_hours_total')}
|
||||
value={formatHours(statsTotals.totalMotorHours)}
|
||||
unit={t('stats.unit_h')}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Share2 size={20} />}
|
||||
label={t('profile.stats_shared_logbooks')}
|
||||
value={String(sharedLogbookCount)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<KpiCard
|
||||
icon={<Calendar size={20} />}
|
||||
label={t('profile.stats_account_since')}
|
||||
value={accountAgeLabel}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<AccountDangerZone className="mt-6" />
|
||||
</>
|
||||
) : null}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
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 { 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()
|
||||
}
|
||||
|
||||
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" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ 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 { Ship, Save, Check, Plus, X, Camera, Trash2 } from 'lucide-react'
|
||||
|
||||
interface VesselFormProps {
|
||||
@@ -251,6 +252,7 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
|
||||
})
|
||||
|
||||
setSuccess(true)
|
||||
trackPlausibleEvent(PlausibleEvents.VESSEL_SAVED)
|
||||
setTimeout(() => setSuccess(false), 3000)
|
||||
|
||||
// Trigger background sync task
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { SVGProps } from 'react'
|
||||
|
||||
interface CaptainCapProps extends SVGProps<SVGSVGElement> {
|
||||
size?: number | string
|
||||
}
|
||||
|
||||
/** Skipper-/Kapitänsmütze im Lucide-Strichstil (nicht in lucide-react enthalten). */
|
||||
export default function CaptainCap({ size = 24, ...props }: CaptainCapProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
{...props}
|
||||
>
|
||||
<path d="M5 11c0-3.5 3-6 7-6s7 2.5 7 6" />
|
||||
<path d="M4 11h16" />
|
||||
<path d="M4 11c0 2.5 3.2 4.5 8 4.5S20 13.5 20 11" />
|
||||
<path d="M8 11h8" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode
|
||||
} from 'react'
|
||||
import {
|
||||
clearTourCompleted,
|
||||
isTourCompleted,
|
||||
markTourCompleted,
|
||||
resolveTourUserId
|
||||
} from '../services/appTourStorage.js'
|
||||
import { getStoredDemoFirstEntryId } from '../services/demoLogbook.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
|
||||
export type AppTab = 'vessel' | 'crew' | 'logs' | 'stats' | 'settings'
|
||||
|
||||
export type TourStepId =
|
||||
| 'welcome'
|
||||
| 'nav_logs'
|
||||
| 'entry_list'
|
||||
| 'entry_open'
|
||||
| 'entry_track'
|
||||
| 'nav_vessel'
|
||||
| 'nav_crew'
|
||||
| 'nav_stats'
|
||||
| 'nav_feedback'
|
||||
| 'finish'
|
||||
|
||||
interface TourNavigation {
|
||||
setActiveTab: (tab: AppTab) => void
|
||||
setSelectedEntryId: (entryId: string | null) => void
|
||||
setFeedbackOpen: (open: boolean) => void
|
||||
}
|
||||
|
||||
interface DemoTourContext {
|
||||
firstEntryId: string
|
||||
}
|
||||
|
||||
interface AppTourContextValue {
|
||||
isActive: boolean
|
||||
isDemoTour: boolean
|
||||
currentStepId: TourStepId | null
|
||||
currentStepIndex: number
|
||||
totalSteps: number
|
||||
startTour: (options?: { force?: boolean; demoMode?: boolean }) => void
|
||||
stopTour: () => void
|
||||
restartTour: () => void
|
||||
nextStep: () => void
|
||||
prevStep: () => void
|
||||
skipTour: () => void
|
||||
registerNavigation: (navigation: TourNavigation) => void
|
||||
registerDemoTourContext: (context: DemoTourContext | null) => void
|
||||
requestStartAfterLogin: () => void
|
||||
}
|
||||
|
||||
const FULL_STEP_ORDER: TourStepId[] = [
|
||||
'welcome',
|
||||
'nav_logs',
|
||||
'entry_list',
|
||||
'entry_open',
|
||||
'entry_track',
|
||||
'nav_vessel',
|
||||
'nav_crew',
|
||||
'nav_stats',
|
||||
'nav_feedback',
|
||||
'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))
|
||||
|
||||
function getStepOrder(demoMode: boolean): TourStepId[] {
|
||||
return demoMode ? DEMO_STEP_ORDER : FULL_STEP_ORDER
|
||||
}
|
||||
|
||||
const TARGET_BY_STEP: Partial<Record<TourStepId, string>> = {
|
||||
nav_logs: '[data-tour="nav-logs"]',
|
||||
entry_list: '[data-tour="entry-list"]',
|
||||
entry_open: '[data-tour="entry-first"]',
|
||||
entry_track: '[data-tour="entry-track"]',
|
||||
nav_vessel: '[data-tour="nav-vessel"]',
|
||||
nav_crew: '[data-tour="nav-crew"]',
|
||||
nav_stats: '[data-tour="stats-dashboard"]',
|
||||
nav_feedback: '[data-tour="feedback-form"]'
|
||||
}
|
||||
|
||||
const AppTourContext = createContext<AppTourContextValue | null>(null)
|
||||
|
||||
export function AppTourProvider({ children }: { children: ReactNode }) {
|
||||
const [isActive, setIsActive] = useState(false)
|
||||
const [stepIndex, setStepIndex] = useState(0)
|
||||
const [pendingAfterLogin, setPendingAfterLogin] = useState(false)
|
||||
const [isDemoTour, setIsDemoTour] = useState(false)
|
||||
const navigationRef = useRef<TourNavigation | null>(null)
|
||||
const demoContextRef = useRef<DemoTourContext | null>(null)
|
||||
const tourModeRef = useRef<{ demoMode: boolean }>({ demoMode: false })
|
||||
|
||||
const stepOrder = getStepOrder(isDemoTour)
|
||||
const currentStepId = isActive ? stepOrder[stepIndex] ?? null : null
|
||||
|
||||
const resolveFirstEntryId = useCallback((): string | null => {
|
||||
return demoContextRef.current?.firstEntryId ?? getStoredDemoFirstEntryId()
|
||||
}, [])
|
||||
|
||||
const applyStepSideEffects = useCallback((stepId: TourStepId) => {
|
||||
const nav = navigationRef.current
|
||||
if (!nav) return
|
||||
|
||||
if (stepId === 'nav_logs' || stepId === 'entry_list' || stepId === 'entry_open' || stepId === 'entry_track') {
|
||||
nav.setActiveTab('logs')
|
||||
}
|
||||
if (stepId === 'entry_open' || stepId === 'entry_track') {
|
||||
const firstEntryId = resolveFirstEntryId()
|
||||
if (firstEntryId) nav.setSelectedEntryId(firstEntryId)
|
||||
}
|
||||
if (stepId === 'nav_vessel') {
|
||||
nav.setSelectedEntryId(null)
|
||||
nav.setActiveTab('vessel')
|
||||
}
|
||||
if (stepId === 'nav_crew') {
|
||||
nav.setSelectedEntryId(null)
|
||||
nav.setActiveTab('crew')
|
||||
}
|
||||
if (stepId === 'nav_stats') {
|
||||
nav.setSelectedEntryId(null)
|
||||
nav.setActiveTab('stats')
|
||||
}
|
||||
if (stepId === 'nav_feedback') {
|
||||
nav.setSelectedEntryId(null)
|
||||
nav.setFeedbackOpen(true)
|
||||
} else {
|
||||
nav.setFeedbackOpen(false)
|
||||
}
|
||||
}, [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)
|
||||
}, [])
|
||||
|
||||
const startTour = useCallback((options?: { force?: boolean; demoMode?: boolean }) => {
|
||||
const demoMode = options?.demoMode === true
|
||||
const userId = resolveTourUserId({ demoMode })
|
||||
if (!userId) return
|
||||
if (!options?.force && isTourCompleted(userId)) return
|
||||
|
||||
tourModeRef.current = { demoMode }
|
||||
setIsDemoTour(demoMode)
|
||||
setStepIndex(0)
|
||||
setIsActive(true)
|
||||
}, [])
|
||||
|
||||
const dismissTour = useCallback((outcome: 'completed' | 'skipped', stepIndexAtDismiss: number) => {
|
||||
const userId = resolveTourUserId({ demoMode: tourModeRef.current.demoMode })
|
||||
if (userId) markTourCompleted(userId)
|
||||
|
||||
const tourProps = tourModeRef.current.demoMode ? { mode: 'demo' } : undefined
|
||||
if (outcome === 'completed') {
|
||||
trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_COMPLETED, tourProps)
|
||||
const nav = navigationRef.current
|
||||
if (nav && !tourModeRef.current.demoMode) {
|
||||
nav.setSelectedEntryId(null)
|
||||
nav.setActiveTab('stats')
|
||||
}
|
||||
} else {
|
||||
const step = getStepOrder(tourModeRef.current.demoMode)[stepIndexAtDismiss] ?? 'welcome'
|
||||
trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_SKIPPED, { step, ...tourProps })
|
||||
}
|
||||
|
||||
tourModeRef.current = { demoMode: false }
|
||||
navigationRef.current?.setFeedbackOpen(false)
|
||||
setIsDemoTour(false)
|
||||
setIsActive(false)
|
||||
setStepIndex(0)
|
||||
}, [])
|
||||
|
||||
const stopTour = useCallback(() => {
|
||||
dismissTour('skipped', stepIndex)
|
||||
}, [dismissTour, stepIndex])
|
||||
|
||||
const skipTour = useCallback(() => {
|
||||
dismissTour('skipped', stepIndex)
|
||||
}, [dismissTour, stepIndex])
|
||||
|
||||
const nextStep = useCallback(() => {
|
||||
const order = getStepOrder(isDemoTour)
|
||||
if (stepIndex + 1 >= order.length) {
|
||||
dismissTour('completed', stepIndex)
|
||||
return
|
||||
}
|
||||
setStepIndex(stepIndex + 1)
|
||||
}, [dismissTour, isDemoTour, stepIndex])
|
||||
|
||||
const prevStep = useCallback(() => {
|
||||
setStepIndex((current) => Math.max(0, current - 1))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return
|
||||
const stepId = getStepOrder(isDemoTour)[stepIndex]
|
||||
if (!stepId) return
|
||||
applyStepSideEffects(stepId)
|
||||
scrollToCurrentTarget(stepId)
|
||||
}, [isActive, isDemoTour, stepIndex, applyStepSideEffects, scrollToCurrentTarget])
|
||||
|
||||
const restartTour = useCallback(() => {
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId) return
|
||||
clearTourCompleted(userId)
|
||||
startTour({ force: true })
|
||||
}, [startTour])
|
||||
|
||||
const registerNavigation = useCallback((navigation: TourNavigation) => {
|
||||
navigationRef.current = navigation
|
||||
}, [])
|
||||
|
||||
const registerDemoTourContext = useCallback((context: DemoTourContext | null) => {
|
||||
demoContextRef.current = context
|
||||
}, [])
|
||||
|
||||
const requestStartAfterLogin = useCallback(() => {
|
||||
setPendingAfterLogin(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingAfterLogin) return
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId || isTourCompleted(userId)) {
|
||||
setPendingAfterLogin(false)
|
||||
return
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
startTour({ force: true })
|
||||
setPendingAfterLogin(false)
|
||||
}, 400)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [pendingAfterLogin, startTour])
|
||||
|
||||
const value = useMemo<AppTourContextValue>(
|
||||
() => ({
|
||||
isActive,
|
||||
isDemoTour,
|
||||
currentStepId,
|
||||
currentStepIndex: stepIndex,
|
||||
totalSteps: stepOrder.length,
|
||||
startTour,
|
||||
stopTour,
|
||||
restartTour,
|
||||
nextStep,
|
||||
prevStep,
|
||||
skipTour,
|
||||
registerNavigation,
|
||||
registerDemoTourContext,
|
||||
requestStartAfterLogin
|
||||
}),
|
||||
[
|
||||
currentStepId,
|
||||
isActive,
|
||||
isDemoTour,
|
||||
nextStep,
|
||||
prevStep,
|
||||
registerDemoTourContext,
|
||||
registerNavigation,
|
||||
requestStartAfterLogin,
|
||||
restartTour,
|
||||
skipTour,
|
||||
startTour,
|
||||
stepIndex,
|
||||
stepOrder.length,
|
||||
stopTour
|
||||
]
|
||||
)
|
||||
|
||||
return <AppTourContext.Provider value={value}>{children}</AppTourContext.Provider>
|
||||
}
|
||||
|
||||
export function useAppTour(): AppTourContextValue {
|
||||
const ctx = useContext(AppTourContext)
|
||||
if (!ctx) {
|
||||
throw new Error('useAppTour must be used within AppTourProvider')
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function getTourStepCopy(
|
||||
stepId: TourStepId,
|
||||
t: (key: string) => string,
|
||||
options?: { demoMode?: boolean }
|
||||
): { title: string; body: string } {
|
||||
if (stepId === 'welcome' && options?.demoMode) {
|
||||
return {
|
||||
title: t('tour.steps.welcome_public.title'),
|
||||
body: t('tour.steps.welcome_public.body')
|
||||
}
|
||||
}
|
||||
return {
|
||||
title: t(`tour.steps.${stepId}.title`),
|
||||
body: t(`tour.steps.${stepId}.body`)
|
||||
}
|
||||
}
|
||||
|
||||
export function getTourTargetSelector(stepId: TourStepId | null): string | null {
|
||||
if (!stepId) return null
|
||||
return TARGET_BY_STEP[stepId] ?? null
|
||||
}
|
||||
|
||||
export function isCenteredTourStep(stepId: TourStepId | null): boolean {
|
||||
return stepId === 'welcome' || stepId === 'finish'
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useMemo,
|
||||
type ReactNode
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDialog } from '../components/ModalDialog.tsx'
|
||||
|
||||
interface UnsavedChangesContextValue {
|
||||
setDirty: (source: string, dirty: boolean) => void
|
||||
confirmLeave: () => Promise<boolean>
|
||||
}
|
||||
|
||||
const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(null)
|
||||
|
||||
export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
|
||||
const { t } = useTranslation()
|
||||
const { showConfirm } = useDialog()
|
||||
const dirtySources = useRef(new Set<string>())
|
||||
|
||||
const setDirty = useCallback((source: string, dirty: boolean) => {
|
||||
if (dirty) dirtySources.current.add(source)
|
||||
else dirtySources.current.delete(source)
|
||||
}, [])
|
||||
|
||||
const confirmLeave = useCallback(async (): Promise<boolean> => {
|
||||
if (dirtySources.current.size === 0) return true
|
||||
return showConfirm(
|
||||
t('common.unsaved_changes_message'),
|
||||
t('common.unsaved_changes_title'),
|
||||
t('common.unsaved_changes_leave'),
|
||||
t('common.unsaved_changes_stay')
|
||||
)
|
||||
}, [showConfirm, t])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
if (dirtySources.current.size === 0) return
|
||||
e.preventDefault()
|
||||
e.returnValue = ''
|
||||
}
|
||||
window.addEventListener('beforeunload', handler)
|
||||
return () => window.removeEventListener('beforeunload', handler)
|
||||
}, [])
|
||||
|
||||
const value = useMemo(() => ({ setDirty, confirmLeave }), [setDirty, confirmLeave])
|
||||
|
||||
return (
|
||||
<UnsavedChangesContext.Provider value={value}>
|
||||
{children}
|
||||
</UnsavedChangesContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useUnsavedChangesContext(): UnsavedChangesContextValue {
|
||||
const ctx = useContext(UnsavedChangesContext)
|
||||
if (!ctx) {
|
||||
throw new Error('useUnsavedChangesContext must be used within UnsavedChangesProvider')
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Register a form/view as having unsaved changes (cleared automatically on unmount). */
|
||||
export function useRegisterUnsavedChanges(source: string, isDirty: boolean) {
|
||||
const { setDirty, confirmLeave } = useUnsavedChangesContext()
|
||||
|
||||
useEffect(() => {
|
||||
setDirty(source, isDirty)
|
||||
return () => setDirty(source, false)
|
||||
}, [source, isDirty, setDirty])
|
||||
|
||||
return { confirmLeave }
|
||||
}
|
||||
@@ -1,37 +1,109 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useRegisterSW } from 'virtual:pwa-register/react'
|
||||
|
||||
const UPDATE_CHECK_INTERVAL_MS = 60 * 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
|
||||
/** Prevent Android PWA cold-start reload loops from onNeedReload. */
|
||||
const PWA_INITIAL_RELOAD_KEY = 'pwa_sw_initial_reload_done'
|
||||
|
||||
function scheduleUpdateChecks(registration: ServiceWorkerRegistration) {
|
||||
function isUpdateSuppressed(): boolean {
|
||||
const suppressUntil = Number(sessionStorage.getItem(UPDATE_SUPPRESS_KEY) || '0')
|
||||
return Date.now() < suppressUntil
|
||||
}
|
||||
|
||||
function suppressUpdatePrompt(durationMs = UPDATE_SUPPRESS_MS): void {
|
||||
sessionStorage.setItem(UPDATE_SUPPRESS_KEY, String(Date.now() + durationMs))
|
||||
}
|
||||
|
||||
function clearUpdateSuppression(): void {
|
||||
sessionStorage.removeItem(UPDATE_SUPPRESS_KEY)
|
||||
}
|
||||
|
||||
function scheduleUpdateChecks(registration: ServiceWorkerRegistration): () => void {
|
||||
const checkForUpdate = () => {
|
||||
if (isUpdateSuppressed()) return
|
||||
registration.update().catch(() => {})
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
checkForUpdate()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
window.setInterval(checkForUpdate, UPDATE_CHECK_INTERVAL_MS)
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
const intervalId = window.setInterval(checkForUpdate, UPDATE_CHECK_INTERVAL_MS)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
window.clearInterval(intervalId)
|
||||
}
|
||||
}
|
||||
|
||||
export function usePwaUpdate() {
|
||||
const cleanupRef = useRef<(() => void) | null>(null)
|
||||
|
||||
const {
|
||||
needRefresh: [needRefresh],
|
||||
needRefresh: [needRefresh, setNeedRefresh],
|
||||
updateServiceWorker
|
||||
} = useRegisterSW({
|
||||
immediate: true,
|
||||
onRegisteredSW(_swUrl: string, registration: ServiceWorkerRegistration | undefined) {
|
||||
if (registration) {
|
||||
scheduleUpdateChecks(registration)
|
||||
immediate: !import.meta.env.DEV,
|
||||
onNeedReload() {
|
||||
// First SW takeover requires one reload; guard against repeated reloads on Android PWA resume.
|
||||
if (sessionStorage.getItem(PWA_INITIAL_RELOAD_KEY)) {
|
||||
return
|
||||
}
|
||||
sessionStorage.setItem(PWA_INITIAL_RELOAD_KEY, '1')
|
||||
clearUpdateSuppression()
|
||||
setNeedRefresh(false)
|
||||
window.location.reload()
|
||||
},
|
||||
onNeedRefresh() {
|
||||
if (isUpdateSuppressed()) return
|
||||
setNeedRefresh(true)
|
||||
},
|
||||
onRegisteredSW(_swUrl: string, registration: ServiceWorkerRegistration | undefined) {
|
||||
if (!registration) return
|
||||
|
||||
if (isUpdateSuppressed() || !registration.waiting) {
|
||||
setNeedRefresh(false)
|
||||
}
|
||||
|
||||
cleanupRef.current?.()
|
||||
cleanupRef.current = scheduleUpdateChecks(registration)
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (isUpdateSuppressed()) {
|
||||
setNeedRefresh(false)
|
||||
}
|
||||
|
||||
return () => {
|
||||
cleanupRef.current?.()
|
||||
cleanupRef.current = null
|
||||
}
|
||||
}, [setNeedRefresh])
|
||||
|
||||
const updateApp = async () => {
|
||||
setNeedRefresh(false)
|
||||
suppressUpdatePrompt()
|
||||
|
||||
await updateServiceWorker(true)
|
||||
|
||||
// vite-plugin-pwa reloads via the "controlling" event; fallback if that does not fire.
|
||||
window.setTimeout(() => {
|
||||
window.location.reload()
|
||||
}, UPDATE_RELOAD_FALLBACK_MS)
|
||||
}
|
||||
|
||||
return { needRefresh, updateApp }
|
||||
const dismissUpdate = () => {
|
||||
setNeedRefresh(false)
|
||||
suppressUpdatePrompt(UPDATE_DISMISS_SUPPRESS_MS)
|
||||
}
|
||||
|
||||
return { needRefresh, updateApp, dismissUpdate }
|
||||
}
|
||||
|
||||
@@ -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,25 +1,36 @@
|
||||
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 { initSeo } from '../utils/seo.js'
|
||||
|
||||
/** JSON files wrap strings in `translation` — register that namespace explicitly. */
|
||||
const resources = {
|
||||
en: { translation: enJson.translation },
|
||||
de: { translation: deJson.translation }
|
||||
}
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources: {
|
||||
en: enTranslation,
|
||||
de: deTranslation
|
||||
},
|
||||
resources,
|
||||
defaultNS: 'translation',
|
||||
fallbackLng: 'en',
|
||||
supportedLngs: ['de', 'en'],
|
||||
nonExplicitSupportedLngs: true,
|
||||
load: 'languageOnly',
|
||||
interpolation: {
|
||||
escapeValue: false // React already escapes values (prevents XSS)
|
||||
},
|
||||
detection: {
|
||||
order: ['localStorage', 'navigator'],
|
||||
order: ['querystring', 'localStorage', 'navigator'],
|
||||
lookupQuerystring: 'lng',
|
||||
caches: ['localStorage']
|
||||
}
|
||||
})
|
||||
|
||||
initSeo(i18n)
|
||||
|
||||
export default i18n
|
||||
|
||||
+430
-57
@@ -2,7 +2,15 @@
|
||||
"translation": {
|
||||
"app": {
|
||||
"name": "Kapteins Daagbok",
|
||||
"tagline": "Privates Yacht-Logbuch"
|
||||
"tagline": "Privates Yacht-Logbuch",
|
||||
"beta": "Beta",
|
||||
"beta_hint": "Beta-Version — Funktionen können sich noch ändern"
|
||||
},
|
||||
"common": {
|
||||
"unsaved_changes_title": "Ungespeicherte Änderungen",
|
||||
"unsaved_changes_message": "Du hast ungespeicherte Änderungen. Möchtest du die Seite wirklich verlassen? Deine Änderungen gehen verloren.",
|
||||
"unsaved_changes_leave": "Verlassen",
|
||||
"unsaved_changes_stay": "Bleiben"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
@@ -10,53 +18,55 @@
|
||||
"crew": "Crew-Liste",
|
||||
"deviation": "Ablenkungstabelle",
|
||||
"logs": "Logbucheinträge",
|
||||
"stats": "Statistik",
|
||||
"settings": "Einstellungen"
|
||||
},
|
||||
"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}}",
|
||||
"quick_login": "Schnell-Login",
|
||||
"forget_account": "Account auf diesem Gerät vergessen",
|
||||
"not_user": "Nicht {{name}}?",
|
||||
"recovery_title": "Ihr Wiederherstellungsschlüssel",
|
||||
"recovery_warning": "WICHTIG: Schreiben Sie diese 12 Wörter auf. Wenn Sie Ihren Passkey und diese Wörter verlieren, können Ihre Daten nicht wiederhergestellt werden.",
|
||||
"recovery_title": "Dein Wiederherstellungsschlüssel",
|
||||
"recovery_warning": "WICHTIG: Schreib diese 12 Wörter auf. Wenn du deinen Passkey und diese Wörter verlierst, können deine Daten nicht wiederhergestellt werden.",
|
||||
"confirm_recovery": "Ich habe die Wörter aufgeschrieben",
|
||||
"status_logged_in": "Angemeldet",
|
||||
"status_logged_out": "Abgemeldet",
|
||||
"copied": "Kopiert!",
|
||||
"copy_phrase": "Schlüssel kopieren",
|
||||
"enter_recovery": "Wiederherstellungsschlüssel eingeben",
|
||||
"recovery_fallback_warning": "Ihr Passkey wurde erfolgreich authentifiziert, aber Ihr Gerät unterstützt keine hardwarebasierte Schlüsselableitung. Geben Sie Ihren 12-Wörter-Wiederherstellungsschlüssel ein, um Ihr Logbuch zu entschlüsseln.",
|
||||
"recovery_placeholder": "Geben Sie Ihren aus 12 Wörtern bestehenden Wiederherstellungsschlüssel getrennt durch Leerzeichen ein...",
|
||||
"recovery_fallback_warning": "Dein Passkey wurde erfolgreich authentifiziert, aber dein Gerät unterstützt keine hardwarebasierte Schlüsselableitung. Gib deinen 12-Wörter-Wiederherstellungsschlüssel ein, um dein Logbuch zu entschlüsseln.",
|
||||
"recovery_placeholder": "Gib deinen aus 12 Wörtern bestehenden Wiederherstellungsschlüssel getrennt durch Leerzeichen ein...",
|
||||
"back": "Zurück",
|
||||
"decrypting": "Entschlüsselung...",
|
||||
"decrypt_logbook": "Logbuch entschlüsseln",
|
||||
"error_incorrect_recovery": "Falscher Wiederherstellungsschlüssel. Entschlüsselung fehlgeschlagen.",
|
||||
"error_decryption_failed": "Entschlüsselung fehlgeschlagen. Bitte überprüfen Sie Ihren Wiederherstellungsschlüssel.",
|
||||
"error_decryption_failed": "Entschlüsselung fehlgeschlagen. Bitte überprüfe deinen Wiederherstellungsschlüssel.",
|
||||
"or_register": "oder Registrieren",
|
||||
"explore_demo": "Demo ohne Account erkunden",
|
||||
"username_placeholder": "Benutzername / Skippername",
|
||||
"processing": "Verarbeitung...",
|
||||
"help": "Hilfe",
|
||||
"setup_pin_title": "Lokale PIN einrichten (Optional)",
|
||||
"setup_pin_warning": "Da Ihr Gerät keine direkte Passkey-Schlüsselableitung unterstützt, müssten Sie andernfalls bei jedem Login auf diesem Gerät Ihren 12-Wörter-Schlüssel eingeben. Richten Sie eine lokale PIN ein, um das zu vermeiden.",
|
||||
"setup_pin_warning": "Da dein Gerät keine direkte Passkey-Schlüsselableitung unterstützt, müsstest du andernfalls bei jedem Login auf diesem Gerät deinen 12-Wörter-Schlüssel eingeben. Richte eine lokale PIN ein, um das zu vermeiden.",
|
||||
"pin_placeholder": "Z.B. 123456",
|
||||
"pin_label": "Lokaler PIN-Code (4-8 Ziffern)",
|
||||
"save_pin": "PIN speichern & Fortfahren",
|
||||
"skip_pin": "Überspringen & recovery verwenden",
|
||||
"enter_pin_title": "Mit PIN entschlüsseln",
|
||||
"enter_pin_warning": "Geben Sie Ihre lokale PIN ein, um den Entschlüsselungsschlüssel auf diesem Gerät freizuschalten.",
|
||||
"enter_pin_placeholder": "Geben Sie Ihre PIN ein...",
|
||||
"enter_pin_warning": "Gib deine lokale PIN ein, um den Entschlüsselungsschlüssel auf diesem Gerät freizuschalten.",
|
||||
"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."
|
||||
},
|
||||
"pwa": {
|
||||
"title": "App installieren",
|
||||
"generic_benefit": "Installieren Sie Kapteins Daagbok auf Ihrem Gerät für schnelleren Zugriff, Offline-Nutzung und dauerhafte Datenspeicherung.",
|
||||
"ios_instructions": "Auf dem iPad/iPhone: Fügen Sie die App zum Home-Bildschirm hinzu, damit Ihre Logbuchdaten geschützt bleiben und die App wie eine native App startet.",
|
||||
"generic_benefit": "Installiere Kapteins Daagbok auf deinem Gerät für schnelleren Zugriff, Offline-Nutzung und dauerhafte Datenspeicherung.",
|
||||
"ios_instructions": "Auf dem iPad/iPhone: Füge die App zum Home-Bildschirm hinzu, damit deine Logbuchdaten geschützt bleiben und die App wie eine native App startet.",
|
||||
"ios_step_share": "Teilen-Symbol in der Safari-Leiste antippen",
|
||||
"ios_step_add": "„Zum Home-Bildschirm“ wählen",
|
||||
"install_now": "Jetzt installieren",
|
||||
@@ -100,7 +110,7 @@
|
||||
"saved": "Schiffsdaten erfolgreich gespeichert!",
|
||||
"loading": "Schiffsdaten werden geladen...",
|
||||
"sails_list": "Besegelung (vorhandene Segel)",
|
||||
"sails_help": "Tragen Sie hier die Segel ein, die an Eurem Schiff zur Verfügung stehen (z. B. Großsegel, Genua, Fock).",
|
||||
"sails_help": "Trag hier die Segel ein, die an deinem Schiff zur Verfügung stehen (z. B. Großsegel, Genua, Fock).",
|
||||
"add_sail": "Segel hinzufügen",
|
||||
"sail_name_placeholder": "z. B. Großsegel",
|
||||
"no_sails": "Keine Segel hinterlegt.",
|
||||
@@ -113,6 +123,13 @@
|
||||
"new_entry": "Neuer Reisetag",
|
||||
"travel_details": "Reisedetails",
|
||||
"add_event": "Neuen Logbucheintrag hinzufügen",
|
||||
"add_event_btn": "Ereignis hinzufügen",
|
||||
"edit_event": "Ereignis bearbeiten",
|
||||
"save_event_btn": "Änderung speichern",
|
||||
"cancel_event_edit": "Abbrechen",
|
||||
"delete_event": "Ereignis löschen",
|
||||
"sign_cleared_skipper_re_sign_title": "Skipper-Unterschrift entfernt",
|
||||
"sign_cleared_skipper_re_sign": "Das Ereignisprotokoll wurde geändert. Die Skipper-Unterschrift wurde entfernt. Bitte erneut freigeben.",
|
||||
"date": "Datum",
|
||||
"day_of_travel": "Tag der Reise / Reisetag",
|
||||
"departure": "Start-Hafen (Reise von)",
|
||||
@@ -134,33 +151,38 @@
|
||||
"sign_passkey_signing": "Passkey wird angefordert…",
|
||||
"sign_passkey_signed": "Freigegeben von {{username}}",
|
||||
"sign_passkey_export": "Passkey: {{username}} ({{date}})",
|
||||
"sign_attribution_export": "{{username}} ({{date}})",
|
||||
"sign_passkey_clear": "Passkey-Freigabe entfernen",
|
||||
"sign_mode_passkey": "Passkey",
|
||||
"sign_mode_classic": "Klassisch",
|
||||
"sign_passkey_failed": "Passkey-Freigabe fehlgeschlagen",
|
||||
"sign_passkey_cancelled": "Passkey-Freigabe abgebrochen",
|
||||
"sign_invalid": "Signatur ungültig — Inhalt wurde geändert",
|
||||
"sign_badge_skipper": "Skipper",
|
||||
"sign_badge_skipper_invalid": "Ungültig",
|
||||
"sign_badge_skipper_title_valid": "Skipper hat freigegeben",
|
||||
"sign_badge_skipper_title_invalid": "Skipper-Signatur ungültig — Inhalt wurde geändert",
|
||||
"sign_classic_or_passkey": "Optional: klassisch unterschreiben oder Passkey-Freigabe oben",
|
||||
"sign_crew_passkey_hint": "Crew-Mitglieder mit Schreibzugriff können per Passkey freigeben",
|
||||
"sign_offline_hint": "Passkey-Freigabe erfordert Internet — klassische Unterschrift offline möglich",
|
||||
"sign_lock_notice": "Nach der Unterschrift sind Änderungen am Logbucheintrag (außer Fotos) nicht möglich, ohne dass Skipper und Crew erneut unterschreiben müssen.",
|
||||
"sign_lock_active": "Dieser Eintrag ist unterschrieben. Änderungen am Logbuch (außer Fotos) entfernen Skipper- und Crew-Unterschrift automatisch.",
|
||||
"sign_lock_warning_title": "Unterschrift bestätigen",
|
||||
"sign_lock_warning": "Nach dem Unterschreiben sind Änderungen am Logbucheintrag (außer Fotos) nicht mehr möglich, ohne dass Skipper und Crew erneut unterschreiben müssen.\n\nMöchten Sie fortfahren?",
|
||||
"sign_lock_warning": "Nach dem Unterschreiben sind Änderungen am Logbucheintrag (außer Fotos) nicht mehr möglich, ohne dass Skipper und Crew erneut unterschreiben müssen.\n\nMöchtest du fortfahren?",
|
||||
"sign_proceed": "Unterschreiben",
|
||||
"sign_cancel": "Abbrechen",
|
||||
"sign_cleared_re_sign_title": "Unterschriften entfernt",
|
||||
"sign_cleared_re_sign": "Der Logbucheintrag wurde geändert. Skipper- und Crew-Unterschrift wurden entfernt. Bitte erneut unterschreiben.",
|
||||
"no_entries": "Keine Logbucheinträge für diese Yacht gefunden. Erstellen Sie Ihren ersten Reisetag!",
|
||||
"no_entries": "Keine Logbucheinträge für diese Yacht gefunden. Erstelle deinen ersten Reisetag!",
|
||||
"back_to_list": "Zurück zur Journal-Liste",
|
||||
"save": "Logbuchseite speichern",
|
||||
"saving": "Wird gespeichert...",
|
||||
"saved": "Logbuchseite erfolgreich gespeichert!",
|
||||
"loading": "Journal wird geladen...",
|
||||
"delete_entry": "Tag löschen",
|
||||
"delete_confirm": "Sind Sie sicher, dass Sie diesen Reisetag unwiderruflich löschen möchten?",
|
||||
"carry_over_tanks_title": "Tankstände übernehmen?",
|
||||
"carry_over_tanks_confirm": "Morgenstände vom letzten Reisetag als Startwerte übernehmen?\n\nFrischwasser: {{fw}} L\nKraftstoff: {{fuel}} L",
|
||||
"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_yes": "Übernehmen",
|
||||
"carry_over_tanks_no": "Mit 0 starten",
|
||||
"event_title": "Chronologisches Ereignisprotokoll",
|
||||
@@ -168,6 +190,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",
|
||||
@@ -183,6 +222,10 @@
|
||||
"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)",
|
||||
"export_csv": "CSV herunterladen",
|
||||
"share_csv": "CSV teilen",
|
||||
@@ -194,16 +237,16 @@
|
||||
"photo_btn": "Foto aufnehmen / Hochladen",
|
||||
"photo_processing": "Wird verarbeitet...",
|
||||
"no_photos": "Noch keine Fotos an diesen Reisetag angehängt.",
|
||||
"photo_delete_confirm": "Sind Sie sicher, dass Sie dieses Foto unwiderruflich löschen möchten?",
|
||||
"photo_delete_confirm": "Bist du sicher, dass du dieses Foto unwiderruflich löschen möchtest?",
|
||||
"confirm_yes": "Ja",
|
||||
"confirm_no": "Nein",
|
||||
"track_upload_title": "GPS-Track (Datei)",
|
||||
"track_upload_points": "Punkte",
|
||||
"gps_tracking_btn_gpx": "Track-Datei herunterladen",
|
||||
"gps_track_upload_help": "Ziehen Sie eine GPX-, KML- oder GeoJSON-Datei hierher oder klicken Sie zum Auswählen",
|
||||
"gps_track_upload_help": "Zieh eine GPX-, KML- oder GeoJSON-Datei hierher oder klicke zum Auswählen",
|
||||
"gps_track_upload_btn": "GPS-Track hochladen",
|
||||
"gps_track_delete": "Track-Datei löschen",
|
||||
"gps_track_delete_confirm": "Sind Sie sicher, dass Sie diese Track-Datei dauerhaft löschen möchten?",
|
||||
"gps_track_delete_confirm": "Bist du sicher, dass du diese Track-Datei dauerhaft löschen möchtest?",
|
||||
"track_distance": "GPS-Strecke (sm)",
|
||||
"track_speed_max": "Max. Geschwindigkeit (kn)",
|
||||
"track_speed_avg": "Ø Geschwindigkeit (kn)",
|
||||
@@ -217,29 +260,162 @@
|
||||
"share_unsupported": "Teilen wird auf diesem Gerät nicht unterstützt. Datei wurde stattdessen heruntergeladen.",
|
||||
"invite_crew": "Crew einladen",
|
||||
"invite_link_copied": "Einladungslink in die Zwischenablage kopiert!",
|
||||
"invite_link_desc": "Teilen Sie diesen Link mit Crewmitgliedern, um ihnen Schreibrechte für dieses Logbuch zu gewähren.",
|
||||
"invite_link_desc": "Teile diesen Link mit Crewmitgliedern, um ihnen Schreibrechte für dieses Logbuch zu gewähren.",
|
||||
"collaborators_list": "Mitglieder / Crew",
|
||||
"revoke": "Entfernen",
|
||||
"revoke_confirm": "Sind Sie sicher, dass Sie diesem Crewmitglied den Zugriff entziehen möchten?",
|
||||
"revoke_confirm": "Bist du sicher, dass du diesem Crewmitglied den Zugriff entziehen möchtest?",
|
||||
"invite_role": "Rolle",
|
||||
"invite_expires": "Link ist 48 Stunden lang gültig"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Ihre Logbücher",
|
||||
"subtitle": "Wählen Sie ein Logbuch aus oder erstellen Sie ein neues, um Ihre Reisen zu verwalten.",
|
||||
"title": "Deine Logbücher",
|
||||
"subtitle": "Wähle ein Logbuch aus oder erstelle ein neues, um deine Reisen zu verwalten.",
|
||||
"create_btn": "Logbuch erstellen",
|
||||
"new_logbook_placeholder": "Name des Logbuchs oder der Yacht",
|
||||
"logout": "Abmelden",
|
||||
"delete_confirm": "Sind Sie sicher, dass Sie dieses Logbuch unwiderruflich löschen möchten? Alle lokalen Daten und Server-Backups werden vernichtet.",
|
||||
"no_logbooks": "Keine Logbücher gefunden. Erstellen Sie Ihr erstes Logbuch, um zu beginnen!",
|
||||
"logged_in_as": "Angemeldet als {{name}}",
|
||||
"delete_confirm": "Bist du sicher, dass du dieses Logbuch unwiderruflich löschen möchtest? Alle lokalen Daten und Server-Kopien werden vernichtet.\n\nTipp: Erstelle vorher unter Einstellungen → Backup & Wiederherstellung eine Sicherungskopie (.daagbok.json), falls du die Daten später behalten möchtest.",
|
||||
"no_logbooks": "Keine Logbücher gefunden. Erstelle dein erstes Logbuch, um zu beginnen!",
|
||||
"loading": "Logbücher werden geladen...",
|
||||
"status_synced": "Synchronisiert",
|
||||
"status_local": "Nur lokaler Cache",
|
||||
"delete_btn": "Logbuch löschen"
|
||||
"delete_btn": "Logbuch löschen",
|
||||
"section_owned": "Meine Logbücher",
|
||||
"section_shared": "Geteilte Logbücher",
|
||||
"section_shared_hint": "Du wurdest als Crew-Mitglied eingeladen. Skipper-Profil und Einstellungen gehören dem Eigner.",
|
||||
"role_owner": "Eigenes Logbuch",
|
||||
"role_owner_hint": "Du bist Eigner und Skipper dieses Logbuchs",
|
||||
"role_crew": "Crew-Zugang",
|
||||
"role_crew_hint": "Eingeladenes Logbuch — du kannst als Crew mitarbeiten und signieren",
|
||||
"role_read": "Nur Lesen",
|
||||
"role_read_hint": "Geteiltes Logbuch — nur Ansicht, keine Bearbeitung",
|
||||
"open_profile": "Profil von {{name}} öffnen",
|
||||
"edit_title": "Logbuch umbenennen",
|
||||
"edit_placeholder": "Neuer Name des Logbuchs",
|
||||
"edit_success": "Logbuch erfolgreich umbenannt",
|
||||
"edit_btn": "Umbenennen"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Benutzerprofil",
|
||||
"subtitle": "Konto, Passkeys und Statistiken für {{name}}",
|
||||
"back": "Zurück zum Dashboard",
|
||||
"loading": "Profil wird geladen…",
|
||||
"load_error": "Profil konnte nicht geladen werden.",
|
||||
"copy_failed": "Kopieren fehlgeschlagen.",
|
||||
"processing": "Wird verarbeitet…",
|
||||
"identity_title": "Konto-Identität",
|
||||
"username": "Benutzername",
|
||||
"user_id": "Benutzer-ID",
|
||||
"copy_user_id": "Benutzer-ID kopieren",
|
||||
"account_since": "Konto seit",
|
||||
"prf_status": "Passkey-Schlüsselableitung (PRF)",
|
||||
"prf_active": "Aktiv",
|
||||
"prf_inactive": "Nicht eingerichtet",
|
||||
"passkeys_title": "Passkeys",
|
||||
"passkeys_desc": "Registriere auf jedem Gerät einen eigenen Passkey. So kannst du dich auch nach einem Plattformwechsel anmelden.",
|
||||
"passkeys_empty": "Keine Passkeys gefunden.",
|
||||
"add_passkey_btn": "Neuen Passkey hinzufügen",
|
||||
"add_passkey_success": "Passkey erfolgreich hinzugefügt.",
|
||||
"add_passkey_failed": "Passkey konnte nicht hinzugefügt werden.",
|
||||
"remove_passkey_btn": "Passkey entfernen",
|
||||
"remove_passkey_last_title": "Letzter Passkey",
|
||||
"remove_passkey_last_desc": "Der einzige Passkey kann nicht entfernt werden, ohne den Zugang zu deinem Konto zu verlieren. Um das Konto vollständig zu löschen, nutze die Gefahrenzone am Ende dieser Seite.",
|
||||
"remove_passkey_failed": "Passkey konnte nicht entfernt werden.",
|
||||
"remove_passkey_confirm_title": "Passkey entfernen?",
|
||||
"remove_passkey_confirm_desc": "Dieses Gerät kann sich danach nicht mehr mit diesem Passkey anmelden.",
|
||||
"remove_passkey_confirm_yes": "Entfernen",
|
||||
"remove_passkey_confirm_no": "Abbrechen",
|
||||
"pin_title": "Lokaler PIN",
|
||||
"pin_status": "Status",
|
||||
"pin_active": "Aktiv auf diesem Gerät",
|
||||
"pin_inactive": "Nicht eingerichtet",
|
||||
"pin_confirm_label": "PIN bestätigen",
|
||||
"pin_confirm_placeholder": "PIN erneut eingeben",
|
||||
"pin_set_btn": "PIN einrichten",
|
||||
"pin_change_btn": "PIN ändern",
|
||||
"pin_remove_btn": "PIN entfernen",
|
||||
"pin_saved": "PIN gespeichert.",
|
||||
"pin_save_failed": "PIN konnte nicht gespeichert werden.",
|
||||
"pin_mismatch": "Die PIN-Eingaben stimmen nicht überein.",
|
||||
"pin_length_error": "Die PIN muss mindestens 4 Zeichen haben.",
|
||||
"pin_no_session": "Sitzung abgelaufen — bitte erneut anmelden.",
|
||||
"remove_pin_confirm_title": "PIN entfernen?",
|
||||
"remove_pin_confirm_desc": "Du musst dich auf diesem Gerät wieder mit Passkey oder Wiederherstellungsschlüssel anmelden.",
|
||||
"remove_pin_confirm_yes": "PIN entfernen",
|
||||
"remove_pin_confirm_no": "Abbrechen",
|
||||
"security_title": "Sicherheits-Checkliste",
|
||||
"security_desc": "Überblick über die wichtigsten Schutzmechanismen deines Kontos.",
|
||||
"security_passkeys_ok": "Mindestens ein Passkey registriert",
|
||||
"security_passkeys_missing": "Kein Passkey registriert",
|
||||
"security_prf_ok": "PRF-Schlüsselableitung aktiv",
|
||||
"security_prf_missing": "PRF nicht eingerichtet",
|
||||
"security_pin_ok": "Lokaler PIN auf diesem Gerät",
|
||||
"security_pin_missing": "Kein lokaler PIN",
|
||||
"security_recovery_ok": "Wiederherstellungsschlüssel eingerichtet",
|
||||
"security_recovery_hint": "Die 12 Wörter wurden bei der Registrierung angezeigt. Bewahre sie offline und getrennt vom Gerät auf. Du kannst unten einen neuen Schlüssel erstellen — der alte wird dann ungültig.",
|
||||
"recovery_rotate_btn": "Neuen Wiederherstellungsschlüssel erstellen",
|
||||
"recovery_rotate_confirm_title": "Neuen Wiederherstellungsschlüssel erstellen?",
|
||||
"recovery_rotate_confirm_desc": "Der bisherige 12-Wörter-Schlüssel wird sofort ungültig. Stelle sicher, dass du den neuen Schlüssel sicher aufbewahrst, bevor du fortfährst.",
|
||||
"recovery_rotate_confirm_yes": "Neuen Schlüssel erstellen",
|
||||
"recovery_rotate_confirm_no": "Abbrechen",
|
||||
"recovery_rotate_new_warning": "WICHTIG: Schreib diese 12 Wörter auf und bewahre sie offline auf. Der bisherige Wiederherstellungsschlüssel ist ab sofort ungültig.",
|
||||
"recovery_rotate_failed": "Wiederherstellungsschlüssel konnte nicht erstellt werden.",
|
||||
"recovery_rotate_no_session": "Verschlüsselungssitzung abgelaufen — bitte abmelden und erneut anmelden, dann erneut versuchen.",
|
||||
"device_title": "Dieses Gerät",
|
||||
"device_desc": "Lokaler Cache, Sync-Status und Schnell-Login auf diesem Browser.",
|
||||
"device_sync_pending": "{{count}} ausstehende Sync-Einträge",
|
||||
"device_sync_ok": "Alle lokalen Änderungen synchronisiert",
|
||||
"device_remembered": "Account für Schnell-Login auf diesem Gerät gespeichert",
|
||||
"device_not_remembered": "Account nicht in der Schnell-Login-Liste",
|
||||
"device_forget_btn": "Account auf diesem Gerät vergessen",
|
||||
"device_forget_confirm_title": "Schnell-Login entfernen?",
|
||||
"device_forget_confirm_desc": "Der Account verschwindet aus der Schnell-Login-Liste auf diesem Gerät. Deine Session und lokalen Logbücher bleiben erhalten.",
|
||||
"device_forget_confirm_yes": "Entfernen",
|
||||
"device_forget_confirm_no": "Abbrechen",
|
||||
"passkey_label": "Name für neuen Passkey (optional)",
|
||||
"passkey_label_placeholder": "z. B. MacBook, iPhone",
|
||||
"passkey_rename_btn": "Name speichern",
|
||||
"passkey_rename_success": "Passkey-Name gespeichert.",
|
||||
"passkey_rename_failed": "Passkey-Name konnte nicht gespeichert werden.",
|
||||
"passkey_unnamed": "Unbenannter Passkey",
|
||||
"stats_title": "Statistiken",
|
||||
"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",
|
||||
"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",
|
||||
"skipper_section": "Skipper-Profil",
|
||||
"skipper_read_only_hint": "Das Skipper-Profil kann nur vom Logbuch-Eigner bearbeitet werden.",
|
||||
"crew_section": "Crew-Liste",
|
||||
"add_crew": "Crew-Mitglied hinzufügen",
|
||||
"edit_crew": "Crew-Mitglied bearbeiten",
|
||||
@@ -258,11 +434,11 @@
|
||||
"save_member": "Mitglied speichern",
|
||||
"saved": "Skipper-Profil erfolgreich gespeichert!",
|
||||
"loading": "Crew-Dateien werden geladen...",
|
||||
"delete_confirm": "Sind Sie sicher, dass Sie dieses Crew-Mitglied entfernen möchten?"
|
||||
"delete_confirm": "Bist du sicher, dass du dieses Crew-Mitglied entfernen möchtest?"
|
||||
},
|
||||
"deviation": {
|
||||
"title": "Ablenkungstabelle (Compass Deviation)",
|
||||
"subtitle": "Tragen Sie die Magnetkompass-Ablenkung (Abl.) für Kurse (MgK) von 000° bis 360° in 10°-Schritten ein.",
|
||||
"subtitle": "Trag die Magnetkompass-Ablenkung (Abl.) für Kurse (MgK) von 000° bis 360° in 10°-Schritten ein.",
|
||||
"heading": "MgK",
|
||||
"deviation": "Ablenkung",
|
||||
"save": "Kalibrierungsgitter speichern",
|
||||
@@ -271,44 +447,241 @@
|
||||
"loading": "Kalibrierungstabelle wird geladen..."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Systemeinstellungen",
|
||||
"subtitle": "Konfigurieren Sie 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": "Ein API-Schlüssel wird benötigt, um Wetterparameter und Seebedingungen automatisch anhand von GPS-Koordinaten abzurufen.",
|
||||
"no_key": "Bitte hinterlegen Sie Ihren OpenWeatherMap API-Schlüssel in den Einstellungen, um Wetterdaten abzurufen.",
|
||||
"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üfen Sie den API-Schlüssel und die Verbindung.",
|
||||
"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 geben Sie einen Ort an oder ermitteln Sie 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",
|
||||
"gps_error": "Bitte gib einen Ort an oder ermittle die GPS-Koordinaten.",
|
||||
"share_title": "Logbuch teilen (Schreibgeschützt)",
|
||||
"share_desc": "Aktivieren Sie diese Option, um einen öffentlichen, schreibgeschützten Link zu erstellen. Jeder mit dem Link kann Ihre Reisen, Yacht-Profile und Besatzung ansehen. Die Verschlüsselungsschlüssel werden niemals an den Server übertragen (sie bleiben im Hash-Teil der URL).",
|
||||
"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",
|
||||
"danger_zone_title": "Gefahrenzone",
|
||||
"danger_zone_desc": "Durch das Löschen Ihres Kontos werden alle Ihre Passkeys, Logbücher, Schiffsdaten, Crew-Profile, Reiseeinträge und E2E-Schlüssel unwiderruflich gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"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",
|
||||
"delete_account_confirm_title": "Konto löschen?",
|
||||
"delete_account_confirm_desc": "Sind Sie absolut sicher, dass Sie Ihr Konto und alle zugehörigen Logbücher und E2E-verschlüsselten Daten unwiderruflich löschen möchten?",
|
||||
"delete_account_confirm_desc": "Bist du absolut sicher, dass du dein Konto und alle zugehörigen Logbücher und E2E-verschlüsselten Daten unwiderruflich löschen möchtest?",
|
||||
"delete_account_confirm_yes": "Ja, Konto und alle Daten löschen",
|
||||
"delete_account_confirm_no": "Abbrechen",
|
||||
"delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuchen Sie es erneut.",
|
||||
"deleting_account": "Konto wird gelöscht…"
|
||||
"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…",
|
||||
"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",
|
||||
"backup_export_desc": "Lädt alle lokalen Daten als .daagbok.json herunter. Bewahre Datei und Passphrase getrennt und sicher auf.",
|
||||
"backup_restore_title": "Backup wiederherstellen",
|
||||
"backup_restore_desc": "Stellt ein Backup in deinem aktuellen Account wieder her — auch nach Registrierung eines neuen Accounts.",
|
||||
"backup_passphrase": "Backup-Passphrase",
|
||||
"backup_passphrase_placeholder": "Mindestens 8 Zeichen",
|
||||
"backup_passphrase_confirm": "Passphrase bestätigen",
|
||||
"backup_passphrase_short": "Die Backup-Passphrase muss mindestens 8 Zeichen lang sein.",
|
||||
"backup_passphrase_mismatch": "Passphrasen stimmen nicht überein.",
|
||||
"backup_wrong_passphrase": "Passphrase falsch oder Backup beschädigt.",
|
||||
"backup_export_btn": "Backup herunterladen",
|
||||
"backup_exporting": "Backup wird erstellt…",
|
||||
"backup_export_success": "Backup erstellt ({{count}} Reisetage).",
|
||||
"backup_file_label": "Backup-Datei (.daagbok.json)",
|
||||
"backup_preview_btn": "Inhalt prüfen",
|
||||
"backup_previewing": "Prüfe…",
|
||||
"backup_restore_btn": "Wiederherstellen",
|
||||
"backup_restoring": "Wird wiederhergestellt…",
|
||||
"backup_restore_success": "Logbuch „{{title}}“ wurde wiederhergestellt.",
|
||||
"backup_restore_cancelled": "Wiederherstellung abgebrochen.",
|
||||
"backup_invalid_json": "Die Datei ist keine gültige JSON-Datei.",
|
||||
"backup_invalid_format": "Unbekanntes oder veraltetes Backup-Format.",
|
||||
"backup_not_owner": "Nur der Logbuch-Eigner kann Backups erstellen.",
|
||||
"backup_not_authenticated": "Bitte melde dich an, um ein Backup wiederherzustellen.",
|
||||
"backup_id_conflict": "Ein Logbuch mit dieser ID existiert bereits.",
|
||||
"backup_overwrite_confirm": "Das vorhandene Logbuch mit gleicher ID wird ersetzt. Fortfahren?",
|
||||
"backup_new_id_confirm": "Das Backup als neues Logbuch mit neuer ID importieren?",
|
||||
"backup_stat_entries": "{{count}} Reisetage",
|
||||
"backup_stat_photos": "{{count}} Fotos",
|
||||
"backup_stat_crew": "{{count}} Crew-Einträge",
|
||||
"backup_stat_tracks": "{{count}} GPS-Tracks",
|
||||
"backup_exported_at": "Exportiert: {{date}}"
|
||||
},
|
||||
"disclaimer": {
|
||||
"title": "Wichtige Hinweise",
|
||||
"intro": "Bitte lies die folgenden Hinweise, bevor du Kapteins Daagbok nutzt.",
|
||||
"e2e_title": "Ende-zu-Ende-Verschlüsselung",
|
||||
"e2e_body": "Deine Logbuchdaten werden Ende-zu-Ende verschlüsselt. Nur du – bzw. Personen mit deinem Schlüssel – können die Inhalte lesen. Auf dem Server werden ausschließlich verschlüsselte Daten gespeichert.",
|
||||
"pwa_title": "Progressive Web App (PWA)",
|
||||
"pwa_body": "Kapteins Daagbok läuft als Progressive Web App in deinem Browser und kann auf deinem Gerät installiert werden – ähnlich wie eine native App, ohne App-Store.",
|
||||
"storage_title": "Lokale Speicherung & Synchronisation",
|
||||
"storage_body": "Deine Daten werden lokal auf deinem Gerät zwischengespeichert (IndexedDB). Bei aktiver Internetverbindung werden Änderungen mit dem Server synchronisiert. Ohne Verbindung kannst du weiterarbeiten; die Synchronisation erfolgt später.",
|
||||
"free_title": "Kostenlos & werbefrei",
|
||||
"free_body": "Kapteins Daagbok ist kostenlos und enthält keine Werbung.",
|
||||
"liability_title": "Haftungsausschluss",
|
||||
"liability_body": "Die Nutzung erfolgt auf eigene Verantwortung. Es wird keine Haftung für Schäden übernommen, die aus der Nutzung der App entstehen – einschließlich fehlerhafter oder unvollständiger Logbucheinträge, Datenverlust oder technischen Störungen.",
|
||||
"warranty_title": "Keine Gewährleistung",
|
||||
"warranty_body": "Es wird keine Gewährleistung für die Funktion, Richtigkeit oder Verfügbarkeit des Dienstes übernommen. Der Betrieb kann jederzeit unterbrochen, eingeschränkt oder eingestellt werden.",
|
||||
"copyright": "© 2026 KnorrLabs, Markus F.J. Busche",
|
||||
"accept": "Akzeptieren und fortfahren",
|
||||
"close": "Schließen",
|
||||
"button_title": "Hinweise & Haftungsausschluss"
|
||||
},
|
||||
"feedback": {
|
||||
"button_title": "Feedback senden",
|
||||
"title": "Feedback",
|
||||
"intro": "Teile Fehler, Ideen oder allgemeines Feedback. Deine Nachricht wird über einen sicheren Benachrichtigungskanal an das Projektteam gesendet.",
|
||||
"category_label": "Kategorie",
|
||||
"category_general": "Allgemein",
|
||||
"category_bug": "Fehler melden",
|
||||
"category_feature": "Feature-Wunsch",
|
||||
"contact_label": "E-Mail (optional)",
|
||||
"contact_placeholder": "deine@email.beispiel",
|
||||
"message_label": "Nachricht",
|
||||
"message_placeholder": "Beschreib dein Feedback…",
|
||||
"send": "Senden",
|
||||
"sending": "Wird gesendet…",
|
||||
"cancel": "Abbrechen",
|
||||
"success": "Vielen Dank! Dein Feedback wurde gesendet.",
|
||||
"error_send": "Feedback konnte nicht gesendet werden. Bitte versuche es später erneut.",
|
||||
"error_invalid_email": "Bitte gib eine gültige E-Mail-Adresse ein.",
|
||||
"error_not_configured": "Feedback ist auf diesem Server nicht verfügbar.",
|
||||
"error_rate_limited": "Zu viele Feedback-Nachrichten in kurzer Zeit. Bitte warte einige Minuten.",
|
||||
"error_spam": "Diese Nachricht konnte nicht gesendet werden. Bitte formuliere sie anders."
|
||||
},
|
||||
"demo": {
|
||||
"logbook_title": "Demo-Logbuch Ostsee",
|
||||
"badge": "Demo",
|
||||
"public_banner": "Schreibgeschützte Demo-Ansicht",
|
||||
"cta_register": "Account erstellen",
|
||||
"back_to_login": "Zur Anmeldung"
|
||||
},
|
||||
"invitation": {
|
||||
"error_invalid_key": "Der Einladungslink ist kryptografisch ungültig (Schlüssel fehlerhaft).",
|
||||
"error_missing_key": "Der Einladungslink enthält keinen Entschlüsselungsschlüssel (#key=...). Bitte den vollständigen Link vom Eigner verwenden.",
|
||||
"error_expired": "Diese Einladung ist abgelaufen (48 Stunden gültig).",
|
||||
"error_invalid_token": "Einladungstoken ungültig.",
|
||||
"error_load_failed": "Einladungsdetails konnten nicht geladen werden.",
|
||||
"error_incomplete_session": "Sitzung unvollständig — bitte erneut anmelden (Benutzer-ID fehlt).",
|
||||
"error_accept_failed": "Beitritt fehlgeschlagen.",
|
||||
"error_login_failed": "Passkey-Anmeldung fehlgeschlagen.",
|
||||
"error_username_missing": "Benutzername konnte nicht ermittelt werden — bitte erneut anmelden.",
|
||||
"error_register_failed": "Registrierung fehlgeschlagen.",
|
||||
"loading_joining": "Beitritt...",
|
||||
"loading_checking": "Einladung wird geprüft...",
|
||||
"loading_unlocking": "Logbuch wird freigeschaltet und synchronisiert...",
|
||||
"loading_retrieving_key": "Lade Verschlüsselungsschlüssel...",
|
||||
"error_title": "Einladungsfehler",
|
||||
"back_to_start": "Zurück zum Start",
|
||||
"title": "Logbuch-Einladung",
|
||||
"invited_by": "Einladung von",
|
||||
"vessel_logbook": "Schiff / Logbuch",
|
||||
"signed_in_preparing": "Angemeldet als {{username}}. Beitritt wird vorbereitet...",
|
||||
"join_again": "Erneut beitreten",
|
||||
"login_or_register_hint": "Melde dich an oder registriere ein Konto, um dem Logbuch beizutreten.",
|
||||
"or_sign_up": "ODER NEU REGISTRIEREN",
|
||||
"register_crew_account": "Neues Crew-Konto erstellen",
|
||||
"username_label": "Benutzername",
|
||||
"create_passkey": "Passkey erstellen",
|
||||
"switch_language_en": "English",
|
||||
"switch_language_de": "Deutsch"
|
||||
},
|
||||
"stats": {
|
||||
"title": "Statistik",
|
||||
"subtitle": "Strecken, Verbrauch und Antriebsart auf einen Blick",
|
||||
"scope_label": "Auswertungsbereich",
|
||||
"scope_logbook": "Dieses Logbuch",
|
||||
"scope_account": "Alle Logbücher",
|
||||
"loading": "Statistik wird berechnet…",
|
||||
"no_data": "Noch keine Reisetage vorhanden.",
|
||||
"total_distance": "Gesamtstrecke",
|
||||
"travel_days": "Reisetage",
|
||||
"sail_distance": "Unter Segel",
|
||||
"motor_distance": "Maschinenfahrt",
|
||||
"motor_hours_total": "Maschinenstunden gesamt",
|
||||
"daily_motor_hours": "Maschinenstunden pro Reisetag",
|
||||
"avg_motor_hours": "Ø Maschinenstunden pro Reisetag",
|
||||
"unknown_propulsion": "Unbekannt",
|
||||
"fuel_total": "Kraftstoff gesamt",
|
||||
"water_total": "Wasser gesamt",
|
||||
"daily_etmal": "Tages-Etmale",
|
||||
"daily_consumption": "Tagesverbrauch",
|
||||
"route_overview": "Route",
|
||||
"route_map_title": "Streckenübersicht",
|
||||
"propulsion_title": "Segel vs. Maschine",
|
||||
"propulsion_hint": "Die Aufteilung basiert auf den Logbuch-Events pro Reisetag, nicht auf GPS-Segmenten.",
|
||||
"avg_distance": "Ø pro Reisetag",
|
||||
"avg_fuel": "Ø Kraftstoff",
|
||||
"avg_water": "Ø Wasser",
|
||||
"fuel_per_nm": "Kraftstoff pro sm",
|
||||
"fuel_per_motor_hour": "Kraftstoff pro Maschinenstunde",
|
||||
"daily_fuel_per_motor_hour": "Kraftstoffverbrauch pro Maschinenstunde je Reisetag",
|
||||
"fuel_legend": "Kraftstoff",
|
||||
"water_legend": "Wasser",
|
||||
"unit_nm": "sm",
|
||||
"unit_h": "h",
|
||||
"unit_l": "L",
|
||||
"day_label": "Tag {{day}}",
|
||||
"account_logbooks": "Logbücher im Überblick",
|
||||
"col_logbook": "Logbuch"
|
||||
},
|
||||
"tour": {
|
||||
"skip": "Tour überspringen",
|
||||
"back": "Zurück",
|
||||
"next": "Weiter",
|
||||
"finish": "Fertig",
|
||||
"progress": "Schritt {{current}} von {{total}}",
|
||||
"steps": {
|
||||
"welcome": {
|
||||
"title": "Willkommen an Bord!",
|
||||
"body": "Wir haben ein Demo-Logbuch mit drei Reisetagen in der Kieler Förde für dich angelegt. Die Beispieleinträge kannst du jederzeit löschen, wenn du mit dem eigenen Logbuch starten möchtest. Diese kurze Tour zeigt dir die wichtigsten Funktionen."
|
||||
},
|
||||
"welcome_public": {
|
||||
"title": "Willkommen an Bord!",
|
||||
"body": "Erkunde unser Demo-Logbuch mit drei Reisetagen in der Kieler Förde – ganz ohne Account. Diese kurze Tour zeigt dir Schiffsdaten, Crew und Logbucheinträge."
|
||||
},
|
||||
"nav_logs": {
|
||||
"title": "Logbucheinträge",
|
||||
"body": "Hier verwaltest du deine Reisetage – Abfahrt, Ziel, Wetter, Tankstände und GPS-Tracks."
|
||||
},
|
||||
"entry_list": {
|
||||
"title": "Deine Reisetage",
|
||||
"body": "Jede Karte steht für einen Reisetag. Tippe auf einen Eintrag, um Details zu sehen oder zu bearbeiten."
|
||||
},
|
||||
"entry_open": {
|
||||
"title": "Reisetag öffnen",
|
||||
"body": "So sieht ein ausgefüllter Logbucheintrag aus – mit Events, Tankständen und mehr."
|
||||
},
|
||||
"entry_track": {
|
||||
"title": "GPS-Track",
|
||||
"body": "Lade GPX-Dateien hoch oder sieh bereits gespeicherte Routen auf der Karte – inklusive Distanz und Geschwindigkeit."
|
||||
},
|
||||
"nav_vessel": {
|
||||
"title": "Schiffsdaten",
|
||||
"body": "Hinterlege Name, Maße und technische Daten deiner Yacht – einmal ausfüllen, für alle Reisetage verfügbar."
|
||||
},
|
||||
"nav_crew": {
|
||||
"title": "Crew-Liste",
|
||||
"body": "Verwalte Besatzungsmitglieder und weise sie später Reisetagen zu."
|
||||
},
|
||||
"nav_stats": {
|
||||
"title": "Statistik-Dashboard",
|
||||
"body": "Hier siehst du Reisedistanzen, Verbrauch, Routenkarten und Antriebsanteile – automatisch aus deinen Logbucheinträgen berechnet."
|
||||
},
|
||||
"nav_feedback": {
|
||||
"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."
|
||||
},
|
||||
"finish": {
|
||||
"title": "Alles klar!",
|
||||
"body": "Du landest gleich im Statistik-Dashboard. Die Tour kannst du jederzeit unter Einstellungen erneut starten. Gute Fahrt!"
|
||||
}
|
||||
}
|
||||
},
|
||||
"seo": {
|
||||
"title": "Kapteins Daagbok – Kostenloses digitales Yacht-Logbuch (werbefrei)",
|
||||
"description": "Kostenloses, werbefreies digitales Yacht-Logbuch mit End-to-End-Verschlüsselung und Passkey-Anmeldung. Reisetage, GPS-Tracks, Crew und Schiffsdaten sicher dokumentieren – auch offline als PWA.",
|
||||
"keywords": "Yacht-Logbuch, Schiffstagebuch, Bordlogbuch, Segeln, Passkey, E2E-Verschlüsselung, GPS-Track, maritimes Logbuch, kostenlos, werbefrei, gratis, ohne Werbung",
|
||||
"ogImageAlt": "Kapteins Daagbok Logo"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+399
-26
@@ -2,7 +2,15 @@
|
||||
"translation": {
|
||||
"app": {
|
||||
"name": "Kapteins Daagbok",
|
||||
"tagline": "Private Yacht Logbook"
|
||||
"tagline": "Private Yacht Logbook",
|
||||
"beta": "Beta",
|
||||
"beta_hint": "Beta release — features may still change"
|
||||
},
|
||||
"common": {
|
||||
"unsaved_changes_title": "Unsaved changes",
|
||||
"unsaved_changes_message": "You have unsaved changes. Leave this page anyway? Your changes will be lost.",
|
||||
"unsaved_changes_leave": "Leave",
|
||||
"unsaved_changes_stay": "Stay"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
@@ -10,6 +18,7 @@
|
||||
"crew": "Crew List",
|
||||
"deviation": "Deviation Table",
|
||||
"logs": "Logbook Entries",
|
||||
"stats": "Statistics",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"auth": {
|
||||
@@ -37,6 +46,7 @@
|
||||
"error_incorrect_recovery": "Incorrect recovery phrase. Decryption failed.",
|
||||
"error_decryption_failed": "Decryption failed. Please check your recovery phrase.",
|
||||
"or_register": "or register",
|
||||
"explore_demo": "Explore demo without account",
|
||||
"username_placeholder": "Username / Skipper Name",
|
||||
"processing": "Processing...",
|
||||
"help": "Help",
|
||||
@@ -113,6 +123,13 @@
|
||||
"new_entry": "New Travel Day",
|
||||
"travel_details": "Travel Details",
|
||||
"add_event": "Add Event Log Record",
|
||||
"add_event_btn": "Add Event Entry",
|
||||
"edit_event": "Edit event",
|
||||
"save_event_btn": "Save changes",
|
||||
"cancel_event_edit": "Cancel",
|
||||
"delete_event": "Delete event",
|
||||
"sign_cleared_skipper_re_sign_title": "Skipper signature removed",
|
||||
"sign_cleared_skipper_re_sign": "The event log was changed. The skipper signature was removed. Please sign again.",
|
||||
"date": "Date",
|
||||
"day_of_travel": "Day of Travel",
|
||||
"departure": "Departure Port (von)",
|
||||
@@ -134,12 +151,17 @@
|
||||
"sign_passkey_signing": "Requesting Passkey…",
|
||||
"sign_passkey_signed": "Signed by {{username}}",
|
||||
"sign_passkey_export": "Passkey: {{username}} ({{date}})",
|
||||
"sign_attribution_export": "{{username}} ({{date}})",
|
||||
"sign_passkey_clear": "Remove Passkey signature",
|
||||
"sign_mode_passkey": "Passkey",
|
||||
"sign_mode_classic": "Classic",
|
||||
"sign_passkey_failed": "Passkey signing failed",
|
||||
"sign_passkey_cancelled": "Passkey signing cancelled",
|
||||
"sign_invalid": "Signature invalid — entry content changed",
|
||||
"sign_badge_skipper": "Skipper",
|
||||
"sign_badge_skipper_invalid": "Invalid",
|
||||
"sign_badge_skipper_title_valid": "Signed by skipper",
|
||||
"sign_badge_skipper_title_invalid": "Skipper signature invalid — entry content changed",
|
||||
"sign_classic_or_passkey": "Optional: sign classically below or use Passkey above",
|
||||
"sign_crew_passkey_hint": "Write collaborators can sign with their Passkey",
|
||||
"sign_offline_hint": "Passkey signing requires internet — classic signature works offline",
|
||||
@@ -159,8 +181,8 @@
|
||||
"loading": "Loading journal...",
|
||||
"delete_entry": "Delete Day",
|
||||
"delete_confirm": "Are you sure you want to permanently delete this travel day?",
|
||||
"carry_over_tanks_title": "Carry over tank levels?",
|
||||
"carry_over_tanks_confirm": "Use the previous travel day's closing levels as morning levels?\n\nFreshwater: {{fw}} L\nFuel: {{fuel}} L",
|
||||
"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_yes": "Carry over",
|
||||
"carry_over_tanks_no": "Start at 0",
|
||||
"event_title": "Chronological Event Logbook",
|
||||
@@ -168,6 +190,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",
|
||||
@@ -183,6 +222,10 @@
|
||||
"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)",
|
||||
"export_csv": "Download CSV",
|
||||
"share_csv": "Share CSV",
|
||||
@@ -230,16 +273,149 @@
|
||||
"create_btn": "Create Logbook",
|
||||
"new_logbook_placeholder": "Logbook or Yacht Name",
|
||||
"logout": "Logout",
|
||||
"delete_confirm": "Are you sure you want to permanently delete this logbook? All local cache and server backups will be destroyed.",
|
||||
"logged_in_as": "Signed in as {{name}}",
|
||||
"delete_confirm": "Are you sure you want to permanently delete this logbook? All local data and server copies will be destroyed.\n\nTip: Create a backup first under Settings → Backup & restore (.daagbok.json) if you may need the data later.",
|
||||
"no_logbooks": "No logbooks found. Create your first logbook to begin!",
|
||||
"loading": "Loading logbooks...",
|
||||
"status_synced": "Synced",
|
||||
"status_local": "Local Cache Only",
|
||||
"delete_btn": "Delete logbook"
|
||||
"delete_btn": "Delete logbook",
|
||||
"section_owned": "My logbooks",
|
||||
"section_shared": "Shared logbooks",
|
||||
"section_shared_hint": "You were invited as crew. Skipper profile and settings belong to the owner.",
|
||||
"role_owner": "Own logbook",
|
||||
"role_owner_hint": "You own this logbook and act as skipper",
|
||||
"role_crew": "Crew access",
|
||||
"role_crew_hint": "Invited logbook — you can collaborate and sign as crew",
|
||||
"role_read": "Read only",
|
||||
"role_read_hint": "Shared logbook — view only, no editing",
|
||||
"open_profile": "Open profile for {{name}}",
|
||||
"edit_title": "Rename Logbook",
|
||||
"edit_placeholder": "New name of the logbook",
|
||||
"edit_success": "Logbook renamed successfully",
|
||||
"edit_btn": "Rename"
|
||||
},
|
||||
"profile": {
|
||||
"title": "User profile",
|
||||
"subtitle": "Account, passkeys and statistics for {{name}}",
|
||||
"back": "Back to dashboard",
|
||||
"loading": "Loading profile…",
|
||||
"load_error": "Could not load profile.",
|
||||
"copy_failed": "Copy failed.",
|
||||
"processing": "Processing…",
|
||||
"identity_title": "Account identity",
|
||||
"username": "Username",
|
||||
"user_id": "User ID",
|
||||
"copy_user_id": "Copy user ID",
|
||||
"account_since": "Account since",
|
||||
"prf_status": "Passkey key derivation (PRF)",
|
||||
"prf_active": "Active",
|
||||
"prf_inactive": "Not configured",
|
||||
"passkeys_title": "Passkeys",
|
||||
"passkeys_desc": "Register a passkey on each device you use. This helps when switching platforms or browsers.",
|
||||
"passkeys_empty": "No passkeys found.",
|
||||
"add_passkey_btn": "Add new passkey",
|
||||
"add_passkey_success": "Passkey added successfully.",
|
||||
"add_passkey_failed": "Could not add passkey.",
|
||||
"remove_passkey_btn": "Remove passkey",
|
||||
"remove_passkey_last_title": "Last passkey",
|
||||
"remove_passkey_last_desc": "The only passkey cannot be removed without losing access to your account. To delete the account entirely, use the danger zone at the bottom of this page.",
|
||||
"remove_passkey_failed": "Could not remove passkey.",
|
||||
"remove_passkey_confirm_title": "Remove passkey?",
|
||||
"remove_passkey_confirm_desc": "This device will no longer be able to sign in with this passkey.",
|
||||
"remove_passkey_confirm_yes": "Remove",
|
||||
"remove_passkey_confirm_no": "Cancel",
|
||||
"pin_title": "Local PIN",
|
||||
"pin_status": "Status",
|
||||
"pin_active": "Active on this device",
|
||||
"pin_inactive": "Not configured",
|
||||
"pin_confirm_label": "Confirm PIN",
|
||||
"pin_confirm_placeholder": "Re-enter PIN",
|
||||
"pin_set_btn": "Set PIN",
|
||||
"pin_change_btn": "Change PIN",
|
||||
"pin_remove_btn": "Remove PIN",
|
||||
"pin_saved": "PIN saved.",
|
||||
"pin_save_failed": "Could not save PIN.",
|
||||
"pin_mismatch": "PIN entries do not match.",
|
||||
"pin_length_error": "PIN must be at least 4 characters.",
|
||||
"pin_no_session": "Session expired — please sign in again.",
|
||||
"remove_pin_confirm_title": "Remove PIN?",
|
||||
"remove_pin_confirm_desc": "You will need to sign in on this device with passkey or recovery phrase again.",
|
||||
"remove_pin_confirm_yes": "Remove PIN",
|
||||
"remove_pin_confirm_no": "Cancel",
|
||||
"security_title": "Security checklist",
|
||||
"security_desc": "Overview of the most important protections for your account.",
|
||||
"security_passkeys_ok": "At least one passkey registered",
|
||||
"security_passkeys_missing": "No passkey registered",
|
||||
"security_prf_ok": "PRF key derivation active",
|
||||
"security_prf_missing": "PRF not configured",
|
||||
"security_pin_ok": "Local PIN on this device",
|
||||
"security_pin_missing": "No local PIN",
|
||||
"security_recovery_ok": "Recovery phrase configured",
|
||||
"security_recovery_hint": "The 12 words were shown at registration. Store them offline and separately from this device. You can create a new phrase below — the old one will then be invalidated.",
|
||||
"recovery_rotate_btn": "Create new recovery phrase",
|
||||
"recovery_rotate_confirm_title": "Create new recovery phrase?",
|
||||
"recovery_rotate_confirm_desc": "Your previous 12-word phrase will be invalidated immediately. Make sure you can store the new phrase securely before continuing.",
|
||||
"recovery_rotate_confirm_yes": "Create new phrase",
|
||||
"recovery_rotate_confirm_no": "Cancel",
|
||||
"recovery_rotate_new_warning": "IMPORTANT: Write down these 12 words and store them offline. Your previous recovery phrase is no longer valid.",
|
||||
"recovery_rotate_failed": "Could not create a new recovery phrase.",
|
||||
"recovery_rotate_no_session": "Encryption session expired — please sign out and sign in again, then retry.",
|
||||
"device_title": "This device",
|
||||
"device_desc": "Local cache, sync status, and quick login on this browser.",
|
||||
"device_sync_pending": "{{count}} pending sync items",
|
||||
"device_sync_ok": "All local changes synced",
|
||||
"device_remembered": "Account saved for quick login on this device",
|
||||
"device_not_remembered": "Account not in the quick-login list",
|
||||
"device_forget_btn": "Forget account on this device",
|
||||
"device_forget_confirm_title": "Remove quick login?",
|
||||
"device_forget_confirm_desc": "The account will be removed from the quick-login list on this device. Your session and local logbooks stay on this device.",
|
||||
"device_forget_confirm_yes": "Remove",
|
||||
"device_forget_confirm_no": "Cancel",
|
||||
"passkey_label": "Name for new passkey (optional)",
|
||||
"passkey_label_placeholder": "e.g. MacBook, iPhone",
|
||||
"passkey_rename_btn": "Save name",
|
||||
"passkey_rename_success": "Passkey name saved.",
|
||||
"passkey_rename_failed": "Could not save passkey name.",
|
||||
"passkey_unnamed": "Unnamed passkey",
|
||||
"stats_title": "Statistics",
|
||||
"stats_subtitle": "Across all your logbooks on this device",
|
||||
"stats_logbooks": "Logbooks",
|
||||
"stats_account_since": "Account since",
|
||||
"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",
|
||||
"skipper_section": "Skipper Profile",
|
||||
"skipper_read_only_hint": "The skipper profile can only be edited by the logbook owner.",
|
||||
"crew_section": "Crew List",
|
||||
"add_crew": "Add Crew Member",
|
||||
"edit_crew": "Edit Crew Member",
|
||||
@@ -271,32 +447,17 @@
|
||||
"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": "An API key is required to automatically fetch real-time weather and sea state parameters based on your vessel's GPS coordinates.",
|
||||
"no_key": "Please set your OpenWeatherMap API Key in settings to enable weather auto-fill.",
|
||||
"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",
|
||||
@@ -308,7 +469,219 @@
|
||||
"delete_account_confirm_yes": "Yes, Delete Account and All Data",
|
||||
"delete_account_confirm_no": "Cancel",
|
||||
"delete_account_failed": "Failed to delete account. Please try again.",
|
||||
"deleting_account": "Deleting account…"
|
||||
"delete_backup_hint": "Tip: Before deleting, create backups of your logbooks (.daagbok.json) in each logbook's settings.",
|
||||
"deleting_account": "Deleting account…",
|
||||
"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",
|
||||
"backup_export_desc": "Downloads all local data as a .daagbok.json file. Keep the file and passphrase separate and secure.",
|
||||
"backup_restore_title": "Restore backup",
|
||||
"backup_restore_desc": "Restores a backup into your current account — including after registering a new account.",
|
||||
"backup_passphrase": "Backup passphrase",
|
||||
"backup_passphrase_placeholder": "At least 8 characters",
|
||||
"backup_passphrase_confirm": "Confirm passphrase",
|
||||
"backup_passphrase_short": "The backup passphrase must be at least 8 characters.",
|
||||
"backup_passphrase_mismatch": "Passphrases do not match.",
|
||||
"backup_wrong_passphrase": "Wrong passphrase or corrupted backup.",
|
||||
"backup_export_btn": "Download backup",
|
||||
"backup_exporting": "Creating backup…",
|
||||
"backup_export_success": "Backup created ({{count}} travel days).",
|
||||
"backup_file_label": "Backup file (.daagbok.json)",
|
||||
"backup_preview_btn": "Verify contents",
|
||||
"backup_previewing": "Verifying…",
|
||||
"backup_restore_btn": "Restore",
|
||||
"backup_restoring": "Restoring…",
|
||||
"backup_restore_success": "Logbook “{{title}}” has been restored.",
|
||||
"backup_restore_cancelled": "Restore cancelled.",
|
||||
"backup_invalid_json": "The file is not valid JSON.",
|
||||
"backup_invalid_format": "Unknown or outdated backup format.",
|
||||
"backup_not_owner": "Only the logbook owner can create backups.",
|
||||
"backup_not_authenticated": "Please sign in to restore a backup.",
|
||||
"backup_id_conflict": "A logbook with this ID already exists.",
|
||||
"backup_overwrite_confirm": "The existing logbook with the same ID will be replaced. Continue?",
|
||||
"backup_new_id_confirm": "Import the backup as a new logbook with a new ID?",
|
||||
"backup_stat_entries": "{{count}} travel days",
|
||||
"backup_stat_photos": "{{count}} photos",
|
||||
"backup_stat_crew": "{{count}} crew records",
|
||||
"backup_stat_tracks": "{{count}} GPS tracks",
|
||||
"backup_exported_at": "Exported: {{date}}"
|
||||
},
|
||||
"disclaimer": {
|
||||
"title": "Important notice",
|
||||
"intro": "Please read the following information before using Kapteins Daagbok.",
|
||||
"e2e_title": "End-to-end encryption",
|
||||
"e2e_body": "Your logbook data is encrypted end-to-end. Only you – or people with your key – can read the contents. The server stores encrypted data only.",
|
||||
"pwa_title": "Progressive Web App (PWA)",
|
||||
"pwa_body": "Kapteins Daagbok runs as a Progressive Web App in your browser and can be installed on your device – similar to a native app, without an app store.",
|
||||
"storage_title": "Local storage & sync",
|
||||
"storage_body": "Your data is cached locally on your device (IndexedDB). When online, changes are synced to the server. You can keep working offline; sync happens when connectivity returns.",
|
||||
"free_title": "Free & ad-free",
|
||||
"free_body": "Kapteins Daagbok is free to use and contains no advertising.",
|
||||
"liability_title": "Disclaimer of liability",
|
||||
"liability_body": "Use is at your own risk. No liability is accepted for damages arising from use of the app – including incorrect or incomplete log entries, data loss, or technical failures.",
|
||||
"warranty_title": "No warranty",
|
||||
"warranty_body": "No warranty is provided for functionality, accuracy, or availability of the service. Operation may be interrupted, limited, or discontinued at any time.",
|
||||
"copyright": "© 2026 KnorrLabs, Markus F.J. Busche",
|
||||
"accept": "Accept and continue",
|
||||
"close": "Close",
|
||||
"button_title": "Legal notice & disclaimer"
|
||||
},
|
||||
"feedback": {
|
||||
"button_title": "Send feedback",
|
||||
"title": "Feedback",
|
||||
"intro": "Share bugs, ideas or general feedback. Your message is sent to the project team via a secure notification channel.",
|
||||
"category_label": "Category",
|
||||
"category_general": "General",
|
||||
"category_bug": "Bug report",
|
||||
"category_feature": "Feature request",
|
||||
"contact_label": "Email (optional)",
|
||||
"contact_placeholder": "your@email.example",
|
||||
"message_label": "Message",
|
||||
"message_placeholder": "Describe your feedback…",
|
||||
"send": "Send",
|
||||
"sending": "Sending…",
|
||||
"cancel": "Cancel",
|
||||
"success": "Thank you! Your feedback has been sent.",
|
||||
"error_send": "Could not send feedback. Please try again later.",
|
||||
"error_invalid_email": "Please enter a valid email address.",
|
||||
"error_not_configured": "Feedback is not available on this server.",
|
||||
"error_rate_limited": "Too many feedback messages in a short time. Please wait a few minutes.",
|
||||
"error_spam": "This message could not be sent. Please rephrase it and try again."
|
||||
},
|
||||
"demo": {
|
||||
"logbook_title": "Baltic Sea Demo Logbook",
|
||||
"badge": "Demo",
|
||||
"public_banner": "Read-only demo view",
|
||||
"cta_register": "Create account",
|
||||
"back_to_login": "Back to login"
|
||||
},
|
||||
"invitation": {
|
||||
"error_invalid_key": "The invitation link is cryptographically invalid (corrupted key).",
|
||||
"error_missing_key": "The invitation link is missing the decryption key (#key=...). Please use the complete link from the owner.",
|
||||
"error_expired": "This invitation link has expired (valid for 48 hours only).",
|
||||
"error_invalid_token": "Failed to verify invitation token.",
|
||||
"error_load_failed": "Invitation details could not be retrieved.",
|
||||
"error_incomplete_session": "Incomplete session — please log in again (user ID missing).",
|
||||
"error_accept_failed": "Acceptance failed.",
|
||||
"error_login_failed": "Passkey authentication failed.",
|
||||
"error_username_missing": "Could not determine username — please try logging in again.",
|
||||
"error_register_failed": "Registration failed.",
|
||||
"loading_joining": "Joining...",
|
||||
"loading_checking": "Checking Invitation...",
|
||||
"loading_unlocking": "Unlocking logbook and syncing data...",
|
||||
"loading_retrieving_key": "Retrieving encryption key...",
|
||||
"error_title": "Invitation Error",
|
||||
"back_to_start": "Back to Dashboard",
|
||||
"title": "Logbook Invitation",
|
||||
"invited_by": "INVITED BY",
|
||||
"vessel_logbook": "VESSEL / LOGBOOK",
|
||||
"signed_in_preparing": "Signed in as {{username}}. Preparing to join...",
|
||||
"join_again": "Join again",
|
||||
"login_or_register_hint": "Sign in or register an account to join this logbook.",
|
||||
"or_sign_up": "OR SIGN UP",
|
||||
"register_crew_account": "Register New Crew Account",
|
||||
"username_label": "Username",
|
||||
"create_passkey": "Create Passkey",
|
||||
"switch_language_en": "English",
|
||||
"switch_language_de": "Deutsch"
|
||||
},
|
||||
"stats": {
|
||||
"title": "Statistics",
|
||||
"subtitle": "Routes, consumption and propulsion at a glance",
|
||||
"scope_label": "Scope",
|
||||
"scope_logbook": "This logbook",
|
||||
"scope_account": "All logbooks",
|
||||
"loading": "Calculating statistics…",
|
||||
"no_data": "No travel days yet.",
|
||||
"total_distance": "Total distance",
|
||||
"travel_days": "Travel days",
|
||||
"sail_distance": "Under sail",
|
||||
"motor_distance": "Engine",
|
||||
"motor_hours_total": "Total engine hours",
|
||||
"daily_motor_hours": "Engine hours per travel day",
|
||||
"avg_motor_hours": "Avg. engine hours per travel day",
|
||||
"unknown_propulsion": "Unknown",
|
||||
"fuel_total": "Total fuel",
|
||||
"water_total": "Total water",
|
||||
"daily_etmal": "Daily mileage",
|
||||
"daily_consumption": "Daily consumption",
|
||||
"route_overview": "Route",
|
||||
"route_map_title": "Route overview",
|
||||
"propulsion_title": "Sail vs. engine",
|
||||
"propulsion_hint": "Split is based on logbook events per travel day, not GPS segments.",
|
||||
"avg_distance": "Avg. per travel day",
|
||||
"avg_fuel": "Avg. fuel",
|
||||
"avg_water": "Avg. water",
|
||||
"fuel_per_nm": "Fuel per nm",
|
||||
"fuel_per_motor_hour": "Fuel per engine hour",
|
||||
"daily_fuel_per_motor_hour": "Fuel consumption per engine hour by travel day",
|
||||
"fuel_legend": "Fuel",
|
||||
"water_legend": "Water",
|
||||
"unit_nm": "nm",
|
||||
"unit_h": "h",
|
||||
"unit_l": "L",
|
||||
"day_label": "Day {{day}}",
|
||||
"account_logbooks": "Logbooks overview",
|
||||
"col_logbook": "Logbook"
|
||||
},
|
||||
"tour": {
|
||||
"skip": "Skip tour",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"finish": "Done",
|
||||
"progress": "Step {{current}} of {{total}}",
|
||||
"steps": {
|
||||
"welcome": {
|
||||
"title": "Welcome aboard!",
|
||||
"body": "We created a demo logbook with three travel days in Kiel Bay. You can delete the sample entries anytime when you're ready to start your own logbook. This short tour shows you the key features."
|
||||
},
|
||||
"welcome_public": {
|
||||
"title": "Welcome aboard!",
|
||||
"body": "Explore our demo logbook with three travel days in Kiel Bay — no account required. This short tour shows vessel data, crew, and log entries."
|
||||
},
|
||||
"nav_logs": {
|
||||
"title": "Log entries",
|
||||
"body": "Manage your travel days here – departure, destination, weather, tank levels, and GPS tracks."
|
||||
},
|
||||
"entry_list": {
|
||||
"title": "Your travel days",
|
||||
"body": "Each card represents one travel day. Tap an entry to view or edit the details."
|
||||
},
|
||||
"entry_open": {
|
||||
"title": "Open a travel day",
|
||||
"body": "This is what a filled log entry looks like – with events, tank levels, and more."
|
||||
},
|
||||
"entry_track": {
|
||||
"title": "GPS track",
|
||||
"body": "Upload GPX files or view saved routes on the map – including distance and speed stats."
|
||||
},
|
||||
"nav_vessel": {
|
||||
"title": "Vessel data",
|
||||
"body": "Enter your yacht's name, dimensions, and technical details – fill once, use on every travel day."
|
||||
},
|
||||
"nav_crew": {
|
||||
"title": "Crew list",
|
||||
"body": "Manage crew members and assign them to travel days later."
|
||||
},
|
||||
"nav_stats": {
|
||||
"title": "Statistics dashboard",
|
||||
"body": "View travel distances, consumption, route maps, and propulsion breakdown — calculated automatically from your log entries."
|
||||
},
|
||||
"nav_feedback": {
|
||||
"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."
|
||||
},
|
||||
"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!"
|
||||
}
|
||||
}
|
||||
},
|
||||
"seo": {
|
||||
"title": "Kapteins Daagbok – Free Digital Yacht Logbook (Ad-Free)",
|
||||
"description": "Free, ad-free digital yacht logbook with end-to-end encryption and Passkey sign-in. Document travel days, GPS tracks, crew and vessel data securely — offline-capable PWA.",
|
||||
"keywords": "yacht logbook, ship logbook, sailing log, maritime logbook, passkey, E2E encryption, GPS track, free, ad-free, offline PWA",
|
||||
"ogImageAlt": "Kapteins Daagbok logo"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+48
-7
@@ -3,14 +3,55 @@ import { createRoot } from 'react-dom/client'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import './themes.css'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import './i18n'
|
||||
import App from './App.tsx'
|
||||
import { applyAppearanceToDocument } from './services/appearance.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> {
|
||||
applyAppearanceToDocument()
|
||||
await clearDevServiceWorkerCaches()
|
||||
|
||||
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.',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
export const PlausibleEvents = {
|
||||
ACCOUNT_CREATED: 'Account Created',
|
||||
LOGGED_IN: 'Logged In',
|
||||
LOGBOOK_CREATED: 'Logbook Created',
|
||||
TRAVEL_DAY_CREATED: 'Travel Day Created',
|
||||
TRAVEL_DAY_SAVED: 'Travel Day Saved',
|
||||
ENTRY_SIGNED: 'Entry Signed',
|
||||
LOGBOOK_DELETED: 'Logbook Deleted',
|
||||
ACCOUNT_DELETED: 'Account Deleted',
|
||||
GPS_TRACK_UPLOADED: 'GPS Track Uploaded',
|
||||
VESSEL_SAVED: 'Vessel Saved',
|
||||
CREW_SAVED: 'Crew Saved',
|
||||
ONBOARDING_TOUR_COMPLETED: 'Onboarding Tour Completed',
|
||||
ONBOARDING_TOUR_SKIPPED: 'Onboarding Tour Skipped',
|
||||
INVITE_GENERATED: 'Invite Generated',
|
||||
INVITE_ACCEPTED: 'Invite Accepted',
|
||||
LOGBOOK_SHARED: 'Logbook Shared',
|
||||
PUBLIC_LINK_OPENED: 'Public Link Opened',
|
||||
PDF_EXPORTED: 'PDF Exported',
|
||||
CSV_EXPORTED: 'CSV Exported',
|
||||
CSV_SHARED: 'CSV Shared',
|
||||
PHOTO_UPLOADED: 'Photo Uploaded',
|
||||
BACKUP_EXPORTED: 'Backup Exported',
|
||||
BACKUP_RESTORED: 'Backup Restored',
|
||||
DEMO_OPENED: 'Demo Opened',
|
||||
PUSH_ENABLED: 'Push Enabled',
|
||||
PUSH_DISABLED: 'Push Disabled',
|
||||
FOOTER_LINK_CLICKED: 'Footer Link Clicked',
|
||||
PROFILE_OPENED: 'Profile Opened',
|
||||
PASSKEY_ADDED: 'Passkey Added',
|
||||
PASSKEY_REMOVED: 'Passkey Removed',
|
||||
PASSKEY_RENAMED: 'Passkey Renamed',
|
||||
LAST_PASSKEY_REMOVE_HINTED: 'Last Passkey Remove Hinted',
|
||||
LOCAL_PIN_SET: 'Local PIN Set',
|
||||
LOCAL_PIN_REMOVED: 'Local PIN Removed',
|
||||
DEVICE_FORGOTTEN: 'Device Forgotten',
|
||||
RECOVERY_ROTATED: 'Recovery Rotated'
|
||||
} as const
|
||||
|
||||
export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents]
|
||||
|
||||
export type PlausibleEventProps = Record<string, string | number | boolean>
|
||||
|
||||
export function trackPlausibleEvent(name: PlausibleEventName, props?: PlausibleEventProps): void {
|
||||
if (typeof window.plausible !== 'function') return
|
||||
if (props && Object.keys(props).length > 0) {
|
||||
window.plausible(name, { props })
|
||||
return
|
||||
}
|
||||
window.plausible(name)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch(
|
||||
input: string,
|
||||
init: RequestInit = {}
|
||||
): Promise<Response> {
|
||||
const headers = new Headers(init.headers)
|
||||
if (init.body !== undefined && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
|
||||
return fetch(input, {
|
||||
...init,
|
||||
headers,
|
||||
credentials: 'include'
|
||||
})
|
||||
}
|
||||
|
||||
export async function apiJson<T>(input: string, init: RequestInit = {}): Promise<T> {
|
||||
const res = await apiFetch(input, init)
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
const message =
|
||||
typeof data === 'object' && data && 'error' in data && typeof data.error === 'string'
|
||||
? data.error
|
||||
: `Request failed (${res.status})`
|
||||
throw new ApiError(message, res.status)
|
||||
}
|
||||
return data as T
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export const PUBLIC_DEMO_TOUR_USER_ID = '__public_demo__'
|
||||
|
||||
export function getTourCompletedKey(userId: string): string {
|
||||
return `app_tour_completed_${userId}`
|
||||
}
|
||||
|
||||
export function isTourCompleted(userId: string | null): boolean {
|
||||
if (!userId) return true
|
||||
return localStorage.getItem(getTourCompletedKey(userId)) === '1'
|
||||
}
|
||||
|
||||
export function markTourCompleted(userId: string): void {
|
||||
localStorage.setItem(getTourCompletedKey(userId), '1')
|
||||
}
|
||||
|
||||
export function clearTourCompleted(userId: string): void {
|
||||
localStorage.removeItem(getTourCompletedKey(userId))
|
||||
}
|
||||
|
||||
export function resolveTourUserId(options?: { demoMode?: boolean }): string | null {
|
||||
const activeUserId = localStorage.getItem('active_userid')
|
||||
if (activeUserId) return activeUserId
|
||||
if (options?.demoMode) return PUBLIC_DEMO_TOUR_USER_ID
|
||||
return null
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+224
-75
@@ -6,26 +6,22 @@ import {
|
||||
deriveKeyFromPin,
|
||||
encryptBuffer,
|
||||
decryptBuffer,
|
||||
generateRecoveryPhrase,
|
||||
base64ToBuffer,
|
||||
bufferToBase64
|
||||
generateRecoveryPhrase
|
||||
} from './crypto.js'
|
||||
import { clearLogbookKeysCache } from './logbookKeys.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
|
||||
import { db } from './db.js'
|
||||
import { apiFetch, apiJson } from './api.js'
|
||||
|
||||
const API_BASE = '/api/auth'
|
||||
|
||||
// Shared in-memory container for the active user's session master key
|
||||
// Master key lives in memory only (never localStorage — XSS-resistant).
|
||||
let activeMasterKey: ArrayBuffer | null = null
|
||||
|
||||
// Restore key from localStorage on load if present (survives reload/restart)
|
||||
try {
|
||||
const savedKey = localStorage.getItem('active_master_key')
|
||||
if (savedKey) {
|
||||
activeMasterKey = base64ToBuffer(savedKey)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to restore active master key:', e)
|
||||
localStorage.removeItem('active_master_key')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
export function getActiveMasterKey(): ArrayBuffer | null {
|
||||
@@ -34,17 +30,57 @@ export function getActiveMasterKey(): ArrayBuffer | null {
|
||||
|
||||
export function setActiveMasterKey(key: ArrayBuffer | null) {
|
||||
activeMasterKey = key
|
||||
if (key) {
|
||||
try {
|
||||
localStorage.setItem('active_master_key', bufferToBase64(key))
|
||||
} catch (e) {
|
||||
console.error('Failed to save master key to localStorage:', e)
|
||||
}
|
||||
} else {
|
||||
localStorage.removeItem('active_master_key')
|
||||
}
|
||||
|
||||
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`, {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
export async function reauthWithPasskey(): Promise<boolean> {
|
||||
const options = await apiJson<any>(`${API_BASE}/reauth-options`, {
|
||||
method: 'POST'
|
||||
})
|
||||
|
||||
const credentialResponse = await startAuthentication({ optionsJSON: options })
|
||||
|
||||
await apiJson(`${API_BASE}/reauth-verify`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
credentialResponse,
|
||||
challenge: options.challenge
|
||||
})
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// PIN fallback mechanism functions
|
||||
export async function setLocalPin(pin: string, username: string, masterKey: ArrayBuffer): Promise<void> {
|
||||
const pinKey = await deriveKeyFromPin(pin, username)
|
||||
@@ -151,19 +187,11 @@ export interface RegistrationResult {
|
||||
|
||||
export async function registerUser(username: string): Promise<RegistrationResult> {
|
||||
// 1. Get registration options
|
||||
const optionsRes = await fetch(`${API_BASE}/register-options`, {
|
||||
const options = await apiJson<any>(`${API_BASE}/register-options`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username })
|
||||
})
|
||||
|
||||
if (!optionsRes.ok) {
|
||||
const err = await optionsRes.json()
|
||||
throw new Error(err.error || 'Failed to fetch registration options')
|
||||
}
|
||||
|
||||
const options = await optionsRes.json()
|
||||
|
||||
// Request the PRF extension WITH an evaluation salt. This must match the
|
||||
// salt used during login (PRF_SALT), otherwise the PRF-derived key produced
|
||||
// at login would never match what was stored here and every login would fall
|
||||
@@ -228,9 +256,8 @@ export async function registerUser(username: string): Promise<RegistrationResult
|
||||
const encryptedRecovery = await encryptBuffer(masterKey, recoveryKey)
|
||||
|
||||
// 4. Verify registration on the server
|
||||
const verifyRes = await fetch(`${API_BASE}/register-verify`, {
|
||||
const result = await apiJson<{ verified: boolean; userId: string }>(`${API_BASE}/register-verify`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
credentialResponse,
|
||||
@@ -242,18 +269,13 @@ export async function registerUser(username: string): Promise<RegistrationResult
|
||||
encryptedMasterKeyRecTag: encryptedRecovery.tag
|
||||
})
|
||||
})
|
||||
|
||||
if (!verifyRes.ok) {
|
||||
const err = await verifyRes.json()
|
||||
throw new Error(err.error || 'Failed to verify registration response')
|
||||
}
|
||||
|
||||
const result = await verifyRes.json()
|
||||
if (result.verified) {
|
||||
setActiveMasterKey(masterKey)
|
||||
localStorage.setItem('active_username', username)
|
||||
localStorage.setItem('active_userid', result.userId)
|
||||
rememberUsername(username)
|
||||
sessionStorage.setItem('seed_demo_logbook', '1')
|
||||
trackPlausibleEvent(PlausibleEvents.ACCOUNT_CREATED)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -289,19 +311,11 @@ export async function loginUser(username?: string): Promise<LoginResult> {
|
||||
}
|
||||
|
||||
// 1. Get authentication options
|
||||
const optionsRes = await fetch(`${API_BASE}/login-options`, {
|
||||
const options = await apiJson<any>(`${API_BASE}/login-options`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username })
|
||||
})
|
||||
|
||||
if (!optionsRes.ok) {
|
||||
const err = await optionsRes.json()
|
||||
throw new Error(err.error || 'Failed to fetch login options')
|
||||
}
|
||||
|
||||
const options = await optionsRes.json()
|
||||
|
||||
// Add PRF extension evaluation input.
|
||||
// When the server returned a concrete allowCredentials list we use
|
||||
// `evalByCredential` (keyed by the base64url credential id), which is the
|
||||
@@ -363,21 +377,23 @@ export async function loginUser(username?: string): Promise<LoginResult> {
|
||||
}
|
||||
|
||||
// 3. Verify assertion on the server
|
||||
const verifyRes = await fetch(`${API_BASE}/login-verify`, {
|
||||
const result = await apiJson<{
|
||||
verified: boolean
|
||||
userId: string
|
||||
username: string
|
||||
encryptedMasterKeyPrf: string | null
|
||||
encryptedMasterKeyPrfIv: string | null
|
||||
encryptedMasterKeyPrfTag: string | null
|
||||
encryptedMasterKeyRec: string
|
||||
encryptedMasterKeyRecIv: string
|
||||
encryptedMasterKeyRecTag: string
|
||||
}>(`${API_BASE}/login-verify`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
credentialResponse,
|
||||
challenge: options.challenge
|
||||
})
|
||||
})
|
||||
|
||||
if (!verifyRes.ok) {
|
||||
const err = await verifyRes.json()
|
||||
throw new Error(err.error || 'Failed to verify login response')
|
||||
}
|
||||
|
||||
const result = await verifyRes.json()
|
||||
if (!result.verified) {
|
||||
return { verified: false, prfSuccess: false }
|
||||
}
|
||||
@@ -404,7 +420,12 @@ export async function loginUser(username?: string): Promise<LoginResult> {
|
||||
console.log('PRF extension results first present:', !!prfResults.results?.first)
|
||||
}
|
||||
|
||||
if (prfResults?.results?.first && result.encryptedMasterKeyPrf) {
|
||||
if (
|
||||
prfResults?.results?.first &&
|
||||
result.encryptedMasterKeyPrf &&
|
||||
result.encryptedMasterKeyPrfIv &&
|
||||
result.encryptedMasterKeyPrfTag
|
||||
) {
|
||||
try {
|
||||
const firstBuffer = typeof prfResults.results.first === 'string'
|
||||
? base64urlToBuffer(prfResults.results.first)
|
||||
@@ -472,22 +493,14 @@ export async function completeLoginWithRecovery(
|
||||
const prfKey = await deriveKeyFromPrf(firstBuffer)
|
||||
const encryptedPrf = await encryptBuffer(decryptedMaster, prfKey)
|
||||
console.log('Sending PRF credentials to server...')
|
||||
const enrollRes = await fetch(`${API_BASE}/enroll-prf`, {
|
||||
await apiJson(`${API_BASE}/enroll-prf`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-User-Id': encryptedPayloads.userId
|
||||
},
|
||||
body: JSON.stringify({
|
||||
encryptedMasterKeyPrf: encryptedPrf.ciphertext,
|
||||
encryptedMasterKeyPrfIv: encryptedPrf.iv,
|
||||
encryptedMasterKeyPrfTag: encryptedPrf.tag
|
||||
})
|
||||
})
|
||||
console.log('Enrollment response status:', enrollRes.status)
|
||||
if (!enrollRes.ok) {
|
||||
console.warn('Server rejected PRF enrollment')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to encrypt/enroll master key with PRF key:', err)
|
||||
}
|
||||
@@ -505,25 +518,26 @@ export async function completeLoginWithRecovery(
|
||||
}
|
||||
}
|
||||
|
||||
export function logoutUser() {
|
||||
export async function logoutUser() {
|
||||
setActiveMasterKey(null)
|
||||
clearLogbookKeysCache()
|
||||
localStorage.removeItem('active_username')
|
||||
localStorage.removeItem('active_userid')
|
||||
try {
|
||||
await apiFetch(`${API_BASE}/logout`, { method: 'POST' })
|
||||
} catch {
|
||||
/* ignore network errors on logout */
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAccount(): Promise<boolean> {
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
const username = localStorage.getItem('active_username')
|
||||
if (!userId) return false
|
||||
if (!localStorage.getItem('active_userid')) return false
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/delete-account`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-User-Id': userId
|
||||
}
|
||||
})
|
||||
await reauthWithPasskey()
|
||||
|
||||
const res = await apiFetch(`${API_BASE}/delete-account`, { method: 'DELETE' })
|
||||
|
||||
if (res.ok) {
|
||||
if (username) {
|
||||
@@ -543,7 +557,8 @@ export async function deleteAccount(): Promise<boolean> {
|
||||
])
|
||||
|
||||
// Wipe localStorage and session variables
|
||||
logoutUser()
|
||||
await logoutUser()
|
||||
trackPlausibleEvent(PlausibleEvents.ACCOUNT_DELETED)
|
||||
return true
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -551,3 +566,137 @@ export async function deleteAccount(): Promise<boolean> {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export interface UserProfileCredential {
|
||||
id: string
|
||||
label: string | null
|
||||
credentialIdPreview: string
|
||||
transports: string[]
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
userId: string
|
||||
username: string
|
||||
createdAt: string
|
||||
hasPrfEncryption: boolean
|
||||
credentials: UserProfileCredential[]
|
||||
serverMeta: {
|
||||
ownedLogbookCount: number
|
||||
collaborationCount: number
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchUserProfile(): Promise<UserProfile> {
|
||||
return apiJson<UserProfile>(`${API_BASE}/profile`)
|
||||
}
|
||||
|
||||
async function enrollPrfFromMasterKey(masterKey: ArrayBuffer, prfFirst: ArrayBuffer): Promise<void> {
|
||||
const prfKey = await deriveKeyFromPrf(prfFirst)
|
||||
const encryptedPrf = await encryptBuffer(masterKey, prfKey)
|
||||
await apiJson(`${API_BASE}/enroll-prf`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
encryptedMasterKeyPrf: encryptedPrf.ciphertext,
|
||||
encryptedMasterKeyPrfIv: encryptedPrf.iv,
|
||||
encryptedMasterKeyPrfTag: encryptedPrf.tag
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function addPasskey(label?: string): Promise<void> {
|
||||
await reauthWithPasskey()
|
||||
|
||||
const options = await apiJson<any>(`${API_BASE}/add-credential-options`, {
|
||||
method: 'POST'
|
||||
})
|
||||
|
||||
if (!options.extensions) {
|
||||
options.extensions = {}
|
||||
}
|
||||
options.extensions.prf = { eval: { first: PRF_SALT.buffer } }
|
||||
|
||||
let credentialResponse
|
||||
const prfRequested = !!options.extensions?.prf
|
||||
try {
|
||||
credentialResponse = await startRegistration({ optionsJSON: options })
|
||||
} catch (err: any) {
|
||||
const isOptionError = err.name === 'NotSupportedError' ||
|
||||
err.message?.toLowerCase().includes('options') ||
|
||||
err.message?.toLowerCase().includes('process') ||
|
||||
err.message?.toLowerCase().includes('unable to')
|
||||
if (prfRequested && isOptionError) {
|
||||
console.warn('Add passkey with PRF extension failed, retrying without PRF:', err)
|
||||
if (options.extensions) {
|
||||
delete options.extensions.prf
|
||||
}
|
||||
credentialResponse = await startRegistration({ optionsJSON: options })
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
await apiJson(`${API_BASE}/add-credential-verify`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
credentialResponse,
|
||||
challenge: options.challenge,
|
||||
...(label?.trim() ? { label: label.trim() } : {})
|
||||
})
|
||||
})
|
||||
|
||||
const masterKey = getActiveMasterKey()
|
||||
const prfFirstBuffer = extractPrfFirst(credentialResponse.clientExtensionResults || {})
|
||||
if (masterKey && prfFirstBuffer) {
|
||||
try {
|
||||
await enrollPrfFromMasterKey(masterKey, prfFirstBuffer)
|
||||
} catch (err) {
|
||||
console.error('Failed to enroll PRF after adding passkey:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function removePasskey(credentialDbId: string): Promise<void> {
|
||||
await reauthWithPasskey()
|
||||
|
||||
const res = await apiFetch(`${API_BASE}/credentials/${credentialDbId}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body.error || 'Failed to remove passkey')
|
||||
}
|
||||
}
|
||||
|
||||
export async function renamePasskey(credentialDbId: string, label: string): Promise<void> {
|
||||
await reauthWithPasskey()
|
||||
|
||||
await apiJson(`${API_BASE}/credentials/${credentialDbId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ label })
|
||||
})
|
||||
}
|
||||
|
||||
export async function rotateRecoveryPhrase(): Promise<string> {
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) {
|
||||
throw new Error('NO_ACTIVE_MASTER_KEY')
|
||||
}
|
||||
|
||||
await reauthWithPasskey()
|
||||
|
||||
const recoveryPhrase = generateRecoveryPhrase()
|
||||
const recoveryKey = await deriveKeyFromPhrase(recoveryPhrase)
|
||||
const encryptedRecovery = await encryptBuffer(masterKey, recoveryKey)
|
||||
|
||||
await apiJson(`${API_BASE}/rotate-recovery`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
encryptedMasterKeyRec: encryptedRecovery.ciphertext,
|
||||
encryptedMasterKeyRecIv: encryptedRecovery.iv,
|
||||
encryptedMasterKeyRecTag: encryptedRecovery.tag
|
||||
})
|
||||
})
|
||||
|
||||
return recoveryPhrase
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,9 @@ import { getActiveMasterKey } from './auth.js'
|
||||
import { getLogbookKey } from './logbookKeys.js'
|
||||
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 '';
|
||||
@@ -79,7 +81,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
const headers = [
|
||||
'Date', 'Day of Travel', 'Departure Port', 'Destination Port',
|
||||
'Skipper Signature', 'Crew Signature',
|
||||
'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)',
|
||||
'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)', 'Motor Hours (h)',
|
||||
'Event Time', 'MgK Course', 'RwK Course',
|
||||
'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State',
|
||||
'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)',
|
||||
@@ -93,8 +95,12 @@ 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 = formatAppDateTime(signedAt, i18n.language)
|
||||
return i18n.t('logs.sign_attribution_export', { username, date })
|
||||
}
|
||||
};
|
||||
|
||||
@@ -108,6 +114,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
const trackDist = entry.trackDistanceNm ?? '';
|
||||
const trackMax = entry.trackSpeedMaxKn ?? '';
|
||||
const trackAvg = entry.trackSpeedAvgKn ?? '';
|
||||
const motorH = entry.motorHours ?? '';
|
||||
const fwM = entry.freshwater?.morning ?? '';
|
||||
const fwR = entry.freshwater?.refilled ?? '';
|
||||
const fwE = entry.freshwater?.evening ?? '';
|
||||
@@ -123,7 +130,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
rows.push([
|
||||
dateVal, travelDay, dep, dest,
|
||||
signS, signC,
|
||||
trackDist, trackMax, trackAvg,
|
||||
trackDist, trackMax, trackAvg, motorH,
|
||||
'', '', '',
|
||||
'', '', '', '',
|
||||
'', '', '', '', '',
|
||||
@@ -134,12 +141,12 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
||||
].map(escapeCsvValue));
|
||||
} else {
|
||||
// Sort events chronologically by time
|
||||
const sortedEvents = [...eventsList].sort((a, b) => (a.time || '').localeCompare(b.time || ''));
|
||||
const sortedEvents = sortLogEventsByTime(eventsList);
|
||||
for (const ev of sortedEvents) {
|
||||
rows.push([
|
||||
dateVal, travelDay, dep, dest,
|
||||
signS, signC,
|
||||
trackDist, trackMax, trackAvg,
|
||||
trackDist, trackMax, trackAvg, motorH,
|
||||
ev.time || '', ev.mgk || '', ev.rwk || '',
|
||||
ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '',
|
||||
ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '',
|
||||
|
||||
@@ -6,6 +6,8 @@ export interface LocalLogbook {
|
||||
updatedAt: string
|
||||
isSynced: number // 1 = yes, 0 = pending local modifications
|
||||
isShared?: number // 1 = collaborator copy, 0 or unset = owned
|
||||
isDemo?: number // 1 = demo logbook seeded at registration
|
||||
collaborationRole?: 'READ' | 'WRITE' // set when isShared = 1
|
||||
}
|
||||
|
||||
export interface LocalYacht {
|
||||
@@ -132,6 +134,17 @@ class DaagboxDatabase extends Dexie {
|
||||
gpsTracks: 'entryId, logbookId, updatedAt',
|
||||
logbookKeys: 'logbookId'
|
||||
})
|
||||
this.version(5).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',
|
||||
logbookKeys: 'logbookId'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { createLogbook } from './logbook.js'
|
||||
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 i18n from '../i18n/index.js'
|
||||
import {
|
||||
buildDemoCrewRecords,
|
||||
buildDemoEntryPayloads,
|
||||
buildDemoYachtData
|
||||
} from './demoLogbookData.js'
|
||||
|
||||
export const SEED_DEMO_FLAG = 'seed_demo_logbook'
|
||||
|
||||
export function getDemoLogbookStorageKey(userId: string): string {
|
||||
return `demo_logbook_id_${userId}`
|
||||
}
|
||||
|
||||
export function getDemoFirstEntryStorageKey(userId: string): string {
|
||||
return `demo_first_entry_id_${userId}`
|
||||
}
|
||||
|
||||
async function putEncryptedRecord(
|
||||
logbookId: string,
|
||||
key: ArrayBuffer,
|
||||
type: 'entry' | 'crew' | 'yacht' | 'gpsTrack',
|
||||
payloadId: string,
|
||||
data: unknown,
|
||||
now: string
|
||||
): Promise<void> {
|
||||
const encrypted = await encryptJson(data, key)
|
||||
|
||||
if (type === 'entry') {
|
||||
await db.entries.put({
|
||||
payloadId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
} else if (type === 'crew') {
|
||||
await db.crews.put({
|
||||
payloadId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
} else if (type === 'yacht') {
|
||||
await db.yachts.put({
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
} else if (type === 'gpsTrack') {
|
||||
await db.gpsTracks.put({
|
||||
entryId: payloadId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
}
|
||||
|
||||
await db.syncQueue.put({
|
||||
action: type === 'yacht' ? 'update' : 'create',
|
||||
type,
|
||||
payloadId: type === 'yacht' ? logbookId : payloadId,
|
||||
logbookId,
|
||||
data: JSON.stringify(encrypted),
|
||||
updatedAt: now
|
||||
})
|
||||
}
|
||||
|
||||
async function seedYachtAndCrew(logbookId: string, key: ArrayBuffer, now: string): Promise<void> {
|
||||
const yachtData = buildDemoYachtData()
|
||||
await putEncryptedRecord(logbookId, key, 'yacht', logbookId, yachtData, now)
|
||||
|
||||
for (const crew of buildDemoCrewRecords()) {
|
||||
await putEncryptedRecord(logbookId, key, 'crew', crew.payloadId, crew.data, now)
|
||||
}
|
||||
}
|
||||
|
||||
export interface DemoSeedResult {
|
||||
logbookId: string
|
||||
title: string
|
||||
firstEntryId: string
|
||||
}
|
||||
|
||||
export async function seedDemoLogbookIfNeeded(): Promise<DemoSeedResult | null> {
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId || !getActiveMasterKey()) return null
|
||||
|
||||
const shouldSeed = sessionStorage.getItem(SEED_DEMO_FLAG) === '1'
|
||||
const existingId = localStorage.getItem(getDemoLogbookStorageKey(userId))
|
||||
|
||||
if (existingId) {
|
||||
const existing = await db.logbooks.get(existingId)
|
||||
if (existing) {
|
||||
if (shouldSeed) sessionStorage.removeItem(SEED_DEMO_FLAG)
|
||||
const firstEntryId = localStorage.getItem(getDemoFirstEntryStorageKey(userId)) || ''
|
||||
const title = i18n.t('demo.logbook_title')
|
||||
return { logbookId: existingId, title, firstEntryId }
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldSeed) return null
|
||||
sessionStorage.removeItem(SEED_DEMO_FLAG)
|
||||
|
||||
const title = i18n.t('demo.logbook_title')
|
||||
const logbook = await createLogbook(title)
|
||||
const logbookId = logbook.id
|
||||
|
||||
await db.logbooks.update(logbookId, { isDemo: 1 })
|
||||
localStorage.setItem(getDemoLogbookStorageKey(userId), logbookId)
|
||||
|
||||
const key = (await getLogbookKey(logbookId)) || getActiveMasterKey()
|
||||
if (!key) throw new Error('Encryption key not available for demo seed')
|
||||
|
||||
const now = new Date().toISOString()
|
||||
await seedYachtAndCrew(logbookId, key, now)
|
||||
|
||||
const entryPayloads = buildDemoEntryPayloads()
|
||||
let firstEntryId = ''
|
||||
|
||||
for (const { entryId, entryPayload, trackData } of entryPayloads) {
|
||||
if (!firstEntryId) firstEntryId = entryId
|
||||
await putEncryptedRecord(logbookId, key, 'entry', entryId, entryPayload, now)
|
||||
await putEncryptedRecord(logbookId, key, 'gpsTrack', entryId, trackData, now)
|
||||
}
|
||||
|
||||
localStorage.setItem(getDemoFirstEntryStorageKey(userId), firstEntryId)
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Demo logbook sync failed:', err))
|
||||
|
||||
return { logbookId, title, firstEntryId }
|
||||
}
|
||||
|
||||
export function getStoredDemoLogbookId(): string | null {
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId) return null
|
||||
return localStorage.getItem(getDemoLogbookStorageKey(userId))
|
||||
}
|
||||
|
||||
export function getStoredDemoFirstEntryId(): string | null {
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId) return null
|
||||
return localStorage.getItem(getDemoFirstEntryStorageKey(userId))
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import { parseTrackFile } from './trackUpload.js'
|
||||
import { computeTrackStats } from '../utils/trackStats.js'
|
||||
import i18n from '../i18n/index.js'
|
||||
|
||||
import kielLaboeGpx from '../assets/demo/kiel-laboe.gpx?raw'
|
||||
import laboeDampGpx from '../assets/demo/laboe-damp.gpx?raw'
|
||||
import dampSchleimuendeGpx from '../assets/demo/damp-schleimuende.gpx?raw'
|
||||
|
||||
/** Stable ID for the first demo travel day (public demo tour highlight). */
|
||||
export const PUBLIC_DEMO_FIRST_ENTRY_ID = 'a0000001-0000-4000-8000-000000000001'
|
||||
|
||||
const PUBLIC_DEMO_ENTRY_IDS = [
|
||||
PUBLIC_DEMO_FIRST_ENTRY_ID,
|
||||
'a0000001-0000-4000-8000-000000000002',
|
||||
'a0000001-0000-4000-8000-000000000003'
|
||||
] as const
|
||||
|
||||
const PUBLIC_DEMO_CREW_MEMBER_ID = 'a0000001-0000-4000-8000-000000000010'
|
||||
|
||||
export interface DemoDaySpec {
|
||||
date: string
|
||||
dayOfTravel: string
|
||||
departure: string
|
||||
destination: string
|
||||
gpx: string
|
||||
filename: string
|
||||
freshwater: { morning: number; refilled: number; evening: number; consumption: number }
|
||||
fuel: { morning: number; refilled: number; evening: number; consumption: number }
|
||||
motorHours?: number
|
||||
events: Array<Record<string, string>>
|
||||
}
|
||||
|
||||
export interface DemoCrewRecord {
|
||||
payloadId: string
|
||||
data: {
|
||||
name: string
|
||||
address: string
|
||||
birthDate: string
|
||||
phone: string
|
||||
nationality: string
|
||||
passportNumber: string
|
||||
bloodType: string
|
||||
allergies: string
|
||||
diseases: string
|
||||
role: 'skipper' | 'crew'
|
||||
photo: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export interface PublicDemoFixture {
|
||||
title: string
|
||||
yacht: Record<string, unknown>
|
||||
crews: DemoCrewRecord[]
|
||||
entries: Array<Record<string, unknown> & { payloadId: string }>
|
||||
gpsTracks: Array<{ entryId: string; waypoints: unknown[]; filename: string; gpxContent?: string; fileType: string }>
|
||||
photos: never[]
|
||||
firstEntryId: string
|
||||
}
|
||||
|
||||
export function buildDemoDays(): DemoDaySpec[] {
|
||||
const isDe = i18n.language.startsWith('de')
|
||||
return [
|
||||
{
|
||||
date: '2026-05-29',
|
||||
dayOfTravel: '1',
|
||||
departure: 'Kiel',
|
||||
destination: 'Laboe',
|
||||
gpx: kielLaboeGpx,
|
||||
filename: 'kiel-laboe.gpx',
|
||||
freshwater: { morning: 120, refilled: 0, evening: 105, consumption: 15 },
|
||||
fuel: { morning: 85, refilled: 0, evening: 78, consumption: 7 },
|
||||
events: [
|
||||
{
|
||||
time: '10:15',
|
||||
mgk: '042',
|
||||
rwk: '038',
|
||||
windDirection: 'NW',
|
||||
windStrength: '4 Bft',
|
||||
seaState: isDe ? 'leicht bewegt' : 'slight',
|
||||
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
|
||||
remarks: isDe ? 'Abfahrt Kiellinie' : 'Departure Kiellinie'
|
||||
},
|
||||
{
|
||||
time: '11:20',
|
||||
mgk: '030',
|
||||
rwk: '028',
|
||||
windDirection: 'N',
|
||||
windStrength: '3 Bft',
|
||||
seaState: isDe ? 'ruhig' : 'calm',
|
||||
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
|
||||
remarks: isDe ? 'Ankunft Laboe' : 'Arrival Laboe'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
date: '2026-05-30',
|
||||
dayOfTravel: '2',
|
||||
departure: 'Laboe',
|
||||
destination: 'Damp',
|
||||
gpx: laboeDampGpx,
|
||||
filename: 'laboe-damp.gpx',
|
||||
freshwater: { morning: 105, refilled: 25, evening: 110, consumption: 20 },
|
||||
fuel: { morning: 78, refilled: 0, evening: 70, consumption: 8 },
|
||||
motorHours: 1.5,
|
||||
events: [
|
||||
{
|
||||
time: '09:00',
|
||||
mgk: '055',
|
||||
rwk: '050',
|
||||
windDirection: 'NE',
|
||||
windStrength: '3 Bft',
|
||||
seaState: isDe ? 'leicht bewegt' : 'slight',
|
||||
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
|
||||
remarks: isDe ? 'Auslaufen aus Laboe' : 'Departing Laboe'
|
||||
},
|
||||
{
|
||||
time: '12:30',
|
||||
mgk: '075',
|
||||
rwk: '068',
|
||||
windDirection: 'E',
|
||||
windStrength: '4 Bft',
|
||||
seaState: isDe ? 'mäßig bewegt' : 'moderate',
|
||||
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
|
||||
remarks: isDe ? 'Kurs entlang der Küste' : 'Coastal passage'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
date: '2026-05-31',
|
||||
dayOfTravel: '3',
|
||||
departure: 'Damp',
|
||||
destination: 'Schleimünde',
|
||||
gpx: dampSchleimuendeGpx,
|
||||
filename: 'damp-schleimuende.gpx',
|
||||
freshwater: { morning: 110, refilled: 0, evening: 95, consumption: 15 },
|
||||
fuel: { morning: 70, refilled: 15, evening: 80, consumption: 5 },
|
||||
events: [
|
||||
{
|
||||
time: '08:30',
|
||||
mgk: '290',
|
||||
rwk: '285',
|
||||
windDirection: 'W',
|
||||
windStrength: '4 Bft',
|
||||
seaState: isDe ? 'mäßig bewegt' : 'moderate',
|
||||
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
|
||||
remarks: isDe ? 'Passage zur Schlei' : 'Passage toward Schlei'
|
||||
},
|
||||
{
|
||||
time: '14:00',
|
||||
mgk: '310',
|
||||
rwk: '305',
|
||||
windDirection: 'NW',
|
||||
windStrength: '3 Bft',
|
||||
seaState: isDe ? 'leicht bewegt' : 'slight',
|
||||
sailsOrMotor: isDe ? 'Großsegel + Genua' : 'Mainsail + Genoa',
|
||||
remarks: isDe ? 'Ziel Schleimünde' : 'Destination Schleimünde'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export function buildDemoYachtData(): Record<string, unknown> {
|
||||
const isDe = i18n.language.startsWith('de')
|
||||
return {
|
||||
name: 'Seeadler',
|
||||
vesselType: isDe ? 'Segelyacht' : 'Sailing yacht',
|
||||
lengthM: 12.5,
|
||||
draftM: 1.9,
|
||||
airDraftM: 18,
|
||||
homePort: 'Kiel',
|
||||
charterCompany: '',
|
||||
owner: 'Demo Skipper',
|
||||
registrationNumber: 'D-KI 1234',
|
||||
callSign: 'DA1234',
|
||||
atis: '',
|
||||
mmsi: '',
|
||||
sails: isDe ? ['Großsegel', 'Genua', 'Spinnaker'] : ['Mainsail', 'Genoa', 'Spinnaker'],
|
||||
photo: null
|
||||
}
|
||||
}
|
||||
|
||||
export function buildDemoCrewRecords(): DemoCrewRecord[] {
|
||||
const isDe = i18n.language.startsWith('de')
|
||||
return [
|
||||
{
|
||||
payloadId: 'skipper',
|
||||
data: {
|
||||
name: 'Demo Skipper',
|
||||
address: isDe ? 'Am Hafen 12, 24103 Kiel' : 'Harbour Quay 12, 24103 Kiel',
|
||||
birthDate: '1980-06-15',
|
||||
phone: '+49 431 987654',
|
||||
nationality: isDe ? 'Deutsch' : 'German',
|
||||
passportNumber: 'C12X34Y56',
|
||||
bloodType: '0+',
|
||||
allergies: '',
|
||||
diseases: '',
|
||||
role: 'skipper',
|
||||
photo: null
|
||||
}
|
||||
},
|
||||
{
|
||||
payloadId: PUBLIC_DEMO_CREW_MEMBER_ID,
|
||||
data: {
|
||||
name: 'Anna Müller',
|
||||
address: isDe ? 'Hafenstraße 1, 24103 Kiel' : 'Harbour St 1, 24103 Kiel',
|
||||
birthDate: '1988-04-12',
|
||||
phone: '+49 431 123456',
|
||||
nationality: isDe ? 'Deutsch' : 'German',
|
||||
passportNumber: 'C01X00T47',
|
||||
bloodType: 'A+',
|
||||
allergies: '',
|
||||
diseases: '',
|
||||
role: 'crew',
|
||||
photo: null
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export function buildPublicDemoFixture(): PublicDemoFixture {
|
||||
const title = i18n.t('demo.logbook_title')
|
||||
const yacht = buildDemoYachtData()
|
||||
const crews = buildDemoCrewRecords()
|
||||
const days = buildDemoDays()
|
||||
const entries: PublicDemoFixture['entries'] = []
|
||||
const gpsTracks: PublicDemoFixture['gpsTracks'] = []
|
||||
|
||||
days.forEach((day, index) => {
|
||||
const entryId = PUBLIC_DEMO_ENTRY_IDS[index] ?? crypto.randomUUID()
|
||||
const { waypoints } = parseTrackFile(day.gpx, day.filename)
|
||||
const stats = computeTrackStats(waypoints)
|
||||
|
||||
const entryPayload: Record<string, unknown> = {
|
||||
payloadId: entryId,
|
||||
date: day.date,
|
||||
dayOfTravel: day.dayOfTravel,
|
||||
departure: day.departure,
|
||||
destination: day.destination,
|
||||
freshwater: { ...day.freshwater },
|
||||
fuel: { ...day.fuel },
|
||||
signSkipper: '',
|
||||
signCrew: '',
|
||||
events: day.events
|
||||
}
|
||||
|
||||
if (stats) {
|
||||
entryPayload.trackDistanceNm = stats.distanceNm
|
||||
entryPayload.trackSpeedMaxKn = stats.speedMaxKn
|
||||
entryPayload.trackSpeedAvgKn = stats.speedAvgKn
|
||||
}
|
||||
if (day.motorHours != null && day.motorHours > 0) {
|
||||
entryPayload.motorHours = day.motorHours
|
||||
}
|
||||
|
||||
entries.push(entryPayload as PublicDemoFixture['entries'][number])
|
||||
|
||||
gpsTracks.push({
|
||||
entryId,
|
||||
waypoints,
|
||||
filename: day.filename,
|
||||
gpxContent: day.gpx,
|
||||
fileType: 'gpx'
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
title,
|
||||
yacht,
|
||||
crews,
|
||||
entries,
|
||||
gpsTracks,
|
||||
photos: [],
|
||||
firstEntryId: PUBLIC_DEMO_FIRST_ENTRY_ID
|
||||
}
|
||||
}
|
||||
|
||||
export function getPublicDemoFirstEntryId(): string {
|
||||
return PUBLIC_DEMO_FIRST_ENTRY_ID
|
||||
}
|
||||
|
||||
/** Payloads for encrypted seeding (without payloadId on entries). */
|
||||
export function buildDemoEntryPayloads(): Array<{
|
||||
entryId: string
|
||||
entryPayload: Record<string, unknown>
|
||||
trackData: { waypoints: unknown[]; gpxContent: string; filename: string; fileType: string }
|
||||
}> {
|
||||
const days = buildDemoDays()
|
||||
return days.map((day) => {
|
||||
const entryId = crypto.randomUUID()
|
||||
const { waypoints } = parseTrackFile(day.gpx, day.filename)
|
||||
const stats = computeTrackStats(waypoints)
|
||||
|
||||
const entryPayload: Record<string, unknown> = {
|
||||
date: day.date,
|
||||
dayOfTravel: day.dayOfTravel,
|
||||
departure: day.departure,
|
||||
destination: day.destination,
|
||||
freshwater: { ...day.freshwater },
|
||||
fuel: { ...day.fuel },
|
||||
signSkipper: '',
|
||||
signCrew: '',
|
||||
events: day.events
|
||||
}
|
||||
|
||||
if (stats) {
|
||||
entryPayload.trackDistanceNm = stats.distanceNm
|
||||
entryPayload.trackSpeedMaxKn = stats.speedMaxKn
|
||||
entryPayload.trackSpeedAvgKn = stats.speedAvgKn
|
||||
}
|
||||
if (day.motorHours != null && day.motorHours > 0) {
|
||||
entryPayload.motorHours = day.motorHours
|
||||
}
|
||||
|
||||
return {
|
||||
entryId,
|
||||
entryPayload,
|
||||
trackData: {
|
||||
waypoints,
|
||||
gpxContent: day.gpx,
|
||||
filename: day.filename,
|
||||
fileType: 'gpx'
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { startAuthentication } from '@simplewebauthn/browser'
|
||||
import type { PasskeySignature } from '../types/signatures.js'
|
||||
import { apiJson } from './api.js'
|
||||
|
||||
export async function signLogEntry(params: {
|
||||
logbookId: string
|
||||
@@ -7,32 +8,22 @@ export async function signLogEntry(params: {
|
||||
entryHash: string
|
||||
role: 'skipper' | 'crew'
|
||||
}): Promise<PasskeySignature> {
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId) throw new Error('User not authenticated')
|
||||
if (!localStorage.getItem('active_userid')) throw new Error('User not authenticated')
|
||||
|
||||
const optionsRes = await fetch('/api/sign/options', {
|
||||
const options = await apiJson<any>('/api/sign/options', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-User-Id': userId
|
||||
},
|
||||
body: JSON.stringify(params)
|
||||
})
|
||||
|
||||
if (!optionsRes.ok) {
|
||||
const err = await optionsRes.json().catch(() => ({}))
|
||||
throw new Error(err.error || 'Failed to start passkey signing')
|
||||
}
|
||||
|
||||
const options = await optionsRes.json()
|
||||
const credentialResponse = await startAuthentication({ optionsJSON: options })
|
||||
|
||||
const verifyRes = await fetch('/api/sign/verify', {
|
||||
const result = await apiJson<{
|
||||
userId: string
|
||||
username: string
|
||||
credentialId: string
|
||||
signedAt: string
|
||||
}>('/api/sign/verify', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-User-Id': userId
|
||||
},
|
||||
body: JSON.stringify({
|
||||
credentialResponse,
|
||||
challenge: options.challenge,
|
||||
@@ -43,13 +34,6 @@ export async function signLogEntry(params: {
|
||||
})
|
||||
})
|
||||
|
||||
if (!verifyRes.ok) {
|
||||
const err = await verifyRes.json().catch(() => ({}))
|
||||
throw new Error(err.error || 'Passkey signature verification failed')
|
||||
}
|
||||
|
||||
const result = await verifyRes.json()
|
||||
|
||||
return {
|
||||
kind: 'passkey',
|
||||
version: 1,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { apiFetch } from './api.js'
|
||||
|
||||
export type FeedbackCategory = 'bug' | 'feature' | 'general'
|
||||
|
||||
export class FeedbackApiError extends Error {
|
||||
code: 'NOT_CONFIGURED' | 'REQUEST_FAILED' | 'INVALID_EMAIL' | 'RATE_LIMITED' | 'SPAM_DETECTED'
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
code: 'NOT_CONFIGURED' | 'REQUEST_FAILED' | 'INVALID_EMAIL' | 'RATE_LIMITED' | 'SPAM_DETECTED' = 'REQUEST_FAILED'
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'FeedbackApiError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
export function isValidFeedbackEmail(email: string): boolean {
|
||||
return EMAIL_PATTERN.test(email.trim())
|
||||
}
|
||||
|
||||
export async function sendFeedback(payload: {
|
||||
category: FeedbackCategory
|
||||
message: string
|
||||
contactEmail?: string | null
|
||||
logbookId?: string | null
|
||||
logbookTitle?: string | null
|
||||
openedAt: number
|
||||
website?: string
|
||||
}): Promise<void> {
|
||||
const contactEmail = payload.contactEmail?.trim()
|
||||
if (contactEmail && !isValidFeedbackEmail(contactEmail)) {
|
||||
throw new FeedbackApiError('Invalid email address', 'INVALID_EMAIL')
|
||||
}
|
||||
|
||||
const res = await apiFetch('/api/feedback', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
category: payload.category,
|
||||
message: payload.message,
|
||||
contactEmail: contactEmail || undefined,
|
||||
username: localStorage.getItem('active_username') || undefined,
|
||||
logbookId: payload.logbookId || undefined,
|
||||
logbookTitle: payload.logbookTitle || undefined,
|
||||
appVersion: typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : undefined,
|
||||
pageUrl: window.location.href,
|
||||
openedAt: payload.openedAt,
|
||||
website: payload.website || undefined
|
||||
})
|
||||
})
|
||||
|
||||
if (res.status === 503) {
|
||||
throw new FeedbackApiError('Feedback is not configured on this server', 'NOT_CONFIGURED')
|
||||
}
|
||||
|
||||
if (res.status === 429) {
|
||||
throw new FeedbackApiError('Too many feedback submissions', 'RATE_LIMITED')
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
throw new FeedbackApiError(
|
||||
data.error || 'Failed to send feedback',
|
||||
data.code === 'SPAM_DETECTED' ? 'SPAM_DETECTED' : 'REQUEST_FAILED'
|
||||
)
|
||||
}
|
||||
}
|
||||
+111
-28
@@ -2,15 +2,37 @@ import { db, type LocalLogbook } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { encryptJson, decryptJson, encryptBuffer, decryptBuffer } from './crypto.js'
|
||||
import { getLogbookKey, saveLogbookKey, generateLogbookKey } from './logbookKeys.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
|
||||
import { apiFetch } from './api.js'
|
||||
|
||||
const API_BASE = '/api/logbooks'
|
||||
|
||||
export type LogbookAccessRole = 'OWNER' | 'READ' | 'WRITE'
|
||||
export type CollaborationRole = 'READ' | 'WRITE'
|
||||
|
||||
/** Validates server/cached collaboration role; warns and falls back to WRITE if missing or invalid. */
|
||||
export function parseCollaborationRole(role: unknown, context: string): CollaborationRole {
|
||||
if (role === 'READ' || role === 'WRITE') {
|
||||
return role
|
||||
}
|
||||
|
||||
if (role === undefined || role === null || role === '') {
|
||||
console.warn(`[collaboration] Missing role in ${context}; defaulting to WRITE.`)
|
||||
} else {
|
||||
console.warn(`[collaboration] Unexpected role in ${context}:`, role, '— defaulting to WRITE.')
|
||||
}
|
||||
|
||||
return 'WRITE'
|
||||
}
|
||||
|
||||
export interface DecryptedLogbook {
|
||||
id: string
|
||||
title: string
|
||||
updatedAt: string
|
||||
isSynced: boolean
|
||||
isShared: boolean
|
||||
accessRole: LogbookAccessRole
|
||||
isDemo?: boolean
|
||||
}
|
||||
|
||||
// Helper to decrypt a logbook's title using the active logbook key or master key
|
||||
@@ -45,13 +67,7 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
|
||||
|
||||
if (navigator.onLine) {
|
||||
try {
|
||||
const response = await fetch(API_BASE, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-User-Id': userId
|
||||
}
|
||||
})
|
||||
const response = await apiFetch(API_BASE, { method: 'GET' })
|
||||
|
||||
if (response.ok) {
|
||||
const serverLogbooks = await response.json()
|
||||
@@ -98,13 +114,21 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
|
||||
}
|
||||
|
||||
// Update Dexie database cache
|
||||
const localLogbooks: LocalLogbook[] = serverLogbooks.map((lb: any) => ({
|
||||
id: lb.id,
|
||||
encryptedTitle: lb.encryptedTitle,
|
||||
updatedAt: lb.updatedAt || new Date().toISOString(),
|
||||
isSynced: 1,
|
||||
isShared: lb.userId !== userId ? 1 : 0
|
||||
}))
|
||||
const localById = new Map(localLogbooksArray.map((lb) => [lb.id, lb]))
|
||||
const localLogbooks: LocalLogbook[] = serverLogbooks.map((lb: any) => {
|
||||
const isShared = lb.userId !== userId
|
||||
return {
|
||||
id: lb.id,
|
||||
encryptedTitle: lb.encryptedTitle,
|
||||
updatedAt: lb.updatedAt || new Date().toISOString(),
|
||||
isSynced: 1,
|
||||
isShared: isShared ? 1 : 0,
|
||||
collaborationRole: isShared
|
||||
? parseCollaborationRole(lb.collaborators?.[0]?.role, `fetch logbook ${lb.id}`)
|
||||
: undefined,
|
||||
isDemo: localById.get(lb.id)?.isDemo
|
||||
}
|
||||
})
|
||||
|
||||
// Clear existing cache for this user and insert new ones
|
||||
await db.logbooks.bulkPut(localLogbooks)
|
||||
@@ -126,7 +150,11 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
|
||||
title,
|
||||
updatedAt: lb.updatedAt,
|
||||
isSynced: lb.isSynced === 1,
|
||||
isShared: lb.isShared === 1
|
||||
isShared: lb.isShared === 1,
|
||||
accessRole: lb.isShared === 1
|
||||
? parseCollaborationRole(lb.collaborationRole, `cached logbook ${lb.id}`)
|
||||
: 'OWNER',
|
||||
isDemo: lb.isDemo === 1
|
||||
})
|
||||
}
|
||||
|
||||
@@ -175,12 +203,8 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
|
||||
|
||||
if (navigator.onLine) {
|
||||
try {
|
||||
const response = await fetch(API_BASE, {
|
||||
const response = await apiFetch(API_BASE, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-User-Id': userId
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: localId,
|
||||
...payloadData
|
||||
@@ -202,7 +226,8 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
|
||||
title,
|
||||
updatedAt: serverLb.updatedAt,
|
||||
isSynced: true,
|
||||
isShared: false
|
||||
isShared: false,
|
||||
accessRole: 'OWNER'
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -233,7 +258,8 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
|
||||
title,
|
||||
updatedAt: now,
|
||||
isSynced: false,
|
||||
isShared: false
|
||||
isShared: false,
|
||||
accessRole: 'OWNER'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,12 +292,7 @@ export async function deleteLogbook(id: string): Promise<void> {
|
||||
|
||||
if (navigator.onLine) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-User-Id': userId
|
||||
}
|
||||
})
|
||||
const response = await apiFetch(`${API_BASE}/${id}`, { method: 'DELETE' })
|
||||
if (!response.ok) {
|
||||
console.warn('Server deletion failed or was rejected')
|
||||
}
|
||||
@@ -299,4 +320,66 @@ export async function deleteLogbook(id: string): Promise<void> {
|
||||
|
||||
// Perform local cascading cleanup
|
||||
await deleteLocalLogbookCache(id)
|
||||
trackPlausibleEvent(PlausibleEvents.LOGBOOK_DELETED)
|
||||
}
|
||||
|
||||
// Update the title of a logbook. Encrypts the title and updates locally + on server
|
||||
export async function updateLogbookTitle(id: string, newTitle: string): Promise<void> {
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId) {
|
||||
throw new Error('User not authenticated')
|
||||
}
|
||||
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) {
|
||||
throw new Error('Master key not found. User must log in.')
|
||||
}
|
||||
|
||||
const logbookKey = await getLogbookKey(id) || masterKey
|
||||
|
||||
// E2E Encrypt the new title using the Logbook Key (or master key fallback)
|
||||
const encrypted = await encryptJson(newTitle, logbookKey)
|
||||
const encryptedTitleStr = JSON.stringify(encrypted)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const payloadData = {
|
||||
encryptedTitle: encryptedTitleStr
|
||||
}
|
||||
|
||||
if (navigator.onLine) {
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payloadData)
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
// Update local IndexedDB cache as synced
|
||||
await db.logbooks.update(id, {
|
||||
encryptedTitle: encryptedTitleStr,
|
||||
updatedAt: now,
|
||||
isSynced: 1
|
||||
})
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to update logbook on server, saving locally instead:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// If offline or request failed, store locally as unsynced and add to queue
|
||||
await db.logbooks.update(id, {
|
||||
encryptedTitle: encryptedTitleStr,
|
||||
updatedAt: now,
|
||||
isSynced: 0
|
||||
})
|
||||
|
||||
await db.syncQueue.put({
|
||||
action: 'update',
|
||||
type: 'logbook',
|
||||
payloadId: id,
|
||||
logbookId: id,
|
||||
data: JSON.stringify(payloadData),
|
||||
updatedAt: now
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { apiJson } from './api.js'
|
||||
|
||||
export interface LogbookAccess {
|
||||
isOwner: boolean
|
||||
role: 'OWNER' | 'READ' | 'WRITE'
|
||||
@@ -5,15 +7,10 @@ export interface LogbookAccess {
|
||||
}
|
||||
|
||||
export async function getLogbookAccess(logbookId: string): Promise<LogbookAccess | null> {
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId || !navigator.onLine) return null
|
||||
if (!localStorage.getItem('active_userid') || !navigator.onLine) return null
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/logbooks/${logbookId}/access`, {
|
||||
headers: { 'X-User-Id': userId }
|
||||
})
|
||||
if (!res.ok) return null
|
||||
return res.json()
|
||||
return await apiJson<LogbookAccess>(`/api/logbooks/${logbookId}/access`)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,601 @@
|
||||
import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import {
|
||||
decryptJson,
|
||||
encryptBuffer,
|
||||
decryptBuffer
|
||||
} from './crypto.js'
|
||||
import { decryptLogbookTitle, deleteLocalLogbookCache } from './logbook.js'
|
||||
import { ensureLogbookKey, getLogbookKey, saveLogbookKey } from './logbookKeys.js'
|
||||
import { syncLogbook } from './sync.js'
|
||||
import type { SyncQueueItem } from './db.js'
|
||||
|
||||
export const BACKUP_FORMAT = 'kapteins-daagbok-backup' as const
|
||||
export const BACKUP_VERSION = 1 as const
|
||||
|
||||
export interface LogbookBackupFile {
|
||||
format: typeof BACKUP_FORMAT
|
||||
version: typeof BACKUP_VERSION
|
||||
exportedAt: string
|
||||
logbook: {
|
||||
id: string
|
||||
encryptedTitle: string
|
||||
updatedAt: string
|
||||
isDemo?: boolean
|
||||
}
|
||||
logbookKey: {
|
||||
ciphertext: string
|
||||
iv: string
|
||||
tag: string
|
||||
}
|
||||
payloads: {
|
||||
yacht: {
|
||||
encryptedData: string
|
||||
iv: string
|
||||
tag: string
|
||||
updatedAt: string
|
||||
} | null
|
||||
deviation: {
|
||||
encryptedData: string
|
||||
iv: string
|
||||
tag: string
|
||||
updatedAt: string
|
||||
} | null
|
||||
crews: Array<{
|
||||
payloadId: string
|
||||
encryptedData: string
|
||||
iv: string
|
||||
tag: string
|
||||
updatedAt: string
|
||||
}>
|
||||
entries: Array<{
|
||||
payloadId: string
|
||||
encryptedData: string
|
||||
iv: string
|
||||
tag: string
|
||||
updatedAt: string
|
||||
}>
|
||||
photos: Array<{
|
||||
payloadId: string
|
||||
entryId: string
|
||||
encryptedData: string
|
||||
iv: string
|
||||
tag: string
|
||||
updatedAt: string
|
||||
}>
|
||||
gpsTracks: Array<{
|
||||
entryId: string
|
||||
encryptedData: string
|
||||
iv: string
|
||||
tag: string
|
||||
updatedAt: string
|
||||
}>
|
||||
}
|
||||
counts: {
|
||||
entries: number
|
||||
photos: number
|
||||
crews: number
|
||||
gpsTracks: number
|
||||
hasYacht: boolean
|
||||
hasDeviation: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface LogbookBackupPreview {
|
||||
title: string
|
||||
exportedAt: string
|
||||
sourceLogbookId: string
|
||||
counts: LogbookBackupFile['counts']
|
||||
}
|
||||
|
||||
async function deriveBackupPassphraseKey(passphrase: string): Promise<CryptoKey> {
|
||||
const encoder = new TextEncoder()
|
||||
const passphraseBytes = encoder.encode(passphrase.trim())
|
||||
const saltBytes = encoder.encode('KapteinsDaagbokBackupFileSalt_v1')
|
||||
|
||||
const baseKey = await window.crypto.subtle.importKey(
|
||||
'raw',
|
||||
passphraseBytes,
|
||||
{ name: 'PBKDF2' },
|
||||
false,
|
||||
['deriveKey']
|
||||
)
|
||||
|
||||
return window.crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt: saltBytes,
|
||||
iterations: 100_000,
|
||||
hash: 'SHA-256'
|
||||
},
|
||||
baseKey,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['encrypt', 'decrypt']
|
||||
)
|
||||
}
|
||||
|
||||
async function wrapLogbookKey(logbookKey: ArrayBuffer, passphrase: string) {
|
||||
const key = await deriveBackupPassphraseKey(passphrase)
|
||||
return encryptBuffer(logbookKey, key)
|
||||
}
|
||||
|
||||
async function unwrapLogbookKey(
|
||||
wrapped: LogbookBackupFile['logbookKey'],
|
||||
passphrase: string
|
||||
): Promise<ArrayBuffer> {
|
||||
const key = await deriveBackupPassphraseKey(passphrase)
|
||||
return decryptBuffer(wrapped.ciphertext, wrapped.iv, wrapped.tag, key)
|
||||
}
|
||||
|
||||
function isBackupFile(value: unknown): value is LogbookBackupFile {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const obj = value as Partial<LogbookBackupFile>
|
||||
return (
|
||||
obj.format === BACKUP_FORMAT &&
|
||||
obj.version === BACKUP_VERSION &&
|
||||
typeof obj.exportedAt === 'string' &&
|
||||
!!obj.logbook?.id &&
|
||||
!!obj.logbook?.encryptedTitle &&
|
||||
!!obj.logbookKey?.ciphertext &&
|
||||
!!obj.payloads
|
||||
)
|
||||
}
|
||||
|
||||
function encryptedPayloadData(
|
||||
encryptedData: string,
|
||||
iv: string,
|
||||
tag: string,
|
||||
extra?: Record<string, string>
|
||||
): string {
|
||||
return JSON.stringify({
|
||||
ciphertext: encryptedData,
|
||||
iv,
|
||||
tag,
|
||||
...extra
|
||||
})
|
||||
}
|
||||
|
||||
async function collectLogbookPayloads(logbookId: string): Promise<LogbookBackupFile['payloads']> {
|
||||
const [yacht, deviation, crews, entries, photos, gpsTracks] = await Promise.all([
|
||||
db.yachts.get(logbookId),
|
||||
db.deviations.get(logbookId),
|
||||
db.crews.where({ logbookId }).toArray(),
|
||||
db.entries.where({ logbookId }).toArray(),
|
||||
db.photos.where({ logbookId }).toArray(),
|
||||
db.gpsTracks.where({ logbookId }).toArray()
|
||||
])
|
||||
|
||||
return {
|
||||
yacht: yacht
|
||||
? {
|
||||
encryptedData: yacht.encryptedData,
|
||||
iv: yacht.iv,
|
||||
tag: yacht.tag,
|
||||
updatedAt: yacht.updatedAt
|
||||
}
|
||||
: null,
|
||||
deviation: deviation
|
||||
? {
|
||||
encryptedData: deviation.encryptedData,
|
||||
iv: deviation.iv,
|
||||
tag: deviation.tag,
|
||||
updatedAt: deviation.updatedAt
|
||||
}
|
||||
: null,
|
||||
crews: crews.map((c) => ({
|
||||
payloadId: c.payloadId,
|
||||
encryptedData: c.encryptedData,
|
||||
iv: c.iv,
|
||||
tag: c.tag,
|
||||
updatedAt: c.updatedAt
|
||||
})),
|
||||
entries: entries.map((e) => ({
|
||||
payloadId: e.payloadId,
|
||||
encryptedData: e.encryptedData,
|
||||
iv: e.iv,
|
||||
tag: e.tag,
|
||||
updatedAt: e.updatedAt
|
||||
})),
|
||||
photos: photos.map((p) => ({
|
||||
payloadId: p.payloadId,
|
||||
entryId: p.entryId,
|
||||
encryptedData: p.encryptedData,
|
||||
iv: p.iv,
|
||||
tag: p.tag,
|
||||
updatedAt: p.updatedAt
|
||||
})),
|
||||
gpsTracks: gpsTracks.map((t) => ({
|
||||
entryId: t.entryId,
|
||||
encryptedData: t.encryptedData,
|
||||
iv: t.iv,
|
||||
tag: t.tag,
|
||||
updatedAt: t.updatedAt
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
function remapBackup(
|
||||
backup: LogbookBackupFile,
|
||||
newLogbookId: string
|
||||
): LogbookBackupFile {
|
||||
return {
|
||||
...backup,
|
||||
logbook: {
|
||||
...backup.logbook,
|
||||
id: newLogbookId
|
||||
},
|
||||
payloads: {
|
||||
...backup.payloads,
|
||||
yacht: backup.payloads.yacht
|
||||
? { ...backup.payloads.yacht, updatedAt: backup.payloads.yacht.updatedAt }
|
||||
: null,
|
||||
deviation: backup.payloads.deviation
|
||||
? { ...backup.payloads.deviation, updatedAt: backup.payloads.deviation.updatedAt }
|
||||
: null,
|
||||
crews: backup.payloads.crews.map((c) => ({ ...c })),
|
||||
entries: backup.payloads.entries.map((e) => ({ ...e })),
|
||||
photos: backup.payloads.photos.map((p) => ({ ...p })),
|
||||
gpsTracks: backup.payloads.gpsTracks.map((t) => ({ ...t }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function queueRestoredLogbookForSync(
|
||||
logbookId: string,
|
||||
encryptedTitle: string,
|
||||
logbookKey: ArrayBuffer,
|
||||
payloads: LogbookBackupFile['payloads']
|
||||
): Promise<void> {
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Master key not found')
|
||||
|
||||
const aesMasterKey = await window.crypto.subtle.importKey(
|
||||
'raw',
|
||||
masterKey,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['encrypt']
|
||||
)
|
||||
const encryptedKey = await encryptBuffer(logbookKey, aesMasterKey)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const items: Omit<SyncQueueItem, 'id'>[] = [
|
||||
{
|
||||
action: 'create',
|
||||
type: 'logbook',
|
||||
payloadId: logbookId,
|
||||
logbookId,
|
||||
data: JSON.stringify({
|
||||
encryptedTitle,
|
||||
encryptedKey: encryptedKey.ciphertext,
|
||||
iv: encryptedKey.iv,
|
||||
tag: encryptedKey.tag
|
||||
}),
|
||||
updatedAt: now
|
||||
}
|
||||
]
|
||||
|
||||
if (payloads.yacht) {
|
||||
items.push({
|
||||
action: 'update',
|
||||
type: 'yacht',
|
||||
payloadId: logbookId,
|
||||
logbookId,
|
||||
data: encryptedPayloadData(
|
||||
payloads.yacht.encryptedData,
|
||||
payloads.yacht.iv,
|
||||
payloads.yacht.tag
|
||||
),
|
||||
updatedAt: payloads.yacht.updatedAt
|
||||
})
|
||||
}
|
||||
|
||||
if (payloads.deviation) {
|
||||
items.push({
|
||||
action: 'update',
|
||||
type: 'deviation',
|
||||
payloadId: logbookId,
|
||||
logbookId,
|
||||
data: encryptedPayloadData(
|
||||
payloads.deviation.encryptedData,
|
||||
payloads.deviation.iv,
|
||||
payloads.deviation.tag
|
||||
),
|
||||
updatedAt: payloads.deviation.updatedAt
|
||||
})
|
||||
}
|
||||
|
||||
for (const crew of payloads.crews) {
|
||||
items.push({
|
||||
action: 'create',
|
||||
type: 'crew',
|
||||
payloadId: crew.payloadId,
|
||||
logbookId,
|
||||
data: encryptedPayloadData(crew.encryptedData, crew.iv, crew.tag),
|
||||
updatedAt: crew.updatedAt
|
||||
})
|
||||
}
|
||||
|
||||
for (const entry of payloads.entries) {
|
||||
items.push({
|
||||
action: 'create',
|
||||
type: 'entry',
|
||||
payloadId: entry.payloadId,
|
||||
logbookId,
|
||||
data: encryptedPayloadData(entry.encryptedData, entry.iv, entry.tag),
|
||||
updatedAt: entry.updatedAt
|
||||
})
|
||||
}
|
||||
|
||||
for (const photo of payloads.photos) {
|
||||
items.push({
|
||||
action: 'create',
|
||||
type: 'photo',
|
||||
payloadId: photo.payloadId,
|
||||
logbookId,
|
||||
data: encryptedPayloadData(photo.encryptedData, photo.iv, photo.tag, {
|
||||
entryId: photo.entryId
|
||||
}),
|
||||
updatedAt: photo.updatedAt
|
||||
})
|
||||
}
|
||||
|
||||
for (const track of payloads.gpsTracks) {
|
||||
items.push({
|
||||
action: 'create',
|
||||
type: 'gpsTrack',
|
||||
payloadId: track.entryId,
|
||||
logbookId,
|
||||
data: encryptedPayloadData(track.encryptedData, track.iv, track.tag),
|
||||
updatedAt: track.updatedAt
|
||||
})
|
||||
}
|
||||
|
||||
await db.syncQueue.bulkPut(items)
|
||||
}
|
||||
|
||||
async function writeBackupToDexie(
|
||||
logbookId: string,
|
||||
backup: LogbookBackupFile,
|
||||
logbookKey: ArrayBuffer
|
||||
): Promise<void> {
|
||||
const { logbook, payloads } = backup
|
||||
|
||||
await db.logbooks.put({
|
||||
id: logbookId,
|
||||
encryptedTitle: logbook.encryptedTitle,
|
||||
updatedAt: logbook.updatedAt,
|
||||
isSynced: 0,
|
||||
isShared: 0,
|
||||
isDemo: logbook.isDemo ? 1 : 0
|
||||
})
|
||||
|
||||
await saveLogbookKey(logbookId, logbookKey)
|
||||
|
||||
if (payloads.yacht) {
|
||||
await db.yachts.put({
|
||||
logbookId,
|
||||
encryptedData: payloads.yacht.encryptedData,
|
||||
iv: payloads.yacht.iv,
|
||||
tag: payloads.yacht.tag,
|
||||
updatedAt: payloads.yacht.updatedAt
|
||||
})
|
||||
}
|
||||
|
||||
if (payloads.deviation) {
|
||||
await db.deviations.put({
|
||||
logbookId,
|
||||
encryptedData: payloads.deviation.encryptedData,
|
||||
iv: payloads.deviation.iv,
|
||||
tag: payloads.deviation.tag,
|
||||
updatedAt: payloads.deviation.updatedAt
|
||||
})
|
||||
}
|
||||
|
||||
if (payloads.crews.length > 0) {
|
||||
await db.crews.bulkPut(
|
||||
payloads.crews.map((c) => ({
|
||||
payloadId: c.payloadId,
|
||||
logbookId,
|
||||
encryptedData: c.encryptedData,
|
||||
iv: c.iv,
|
||||
tag: c.tag,
|
||||
updatedAt: c.updatedAt
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
if (payloads.entries.length > 0) {
|
||||
await db.entries.bulkPut(
|
||||
payloads.entries.map((e) => ({
|
||||
payloadId: e.payloadId,
|
||||
logbookId,
|
||||
encryptedData: e.encryptedData,
|
||||
iv: e.iv,
|
||||
tag: e.tag,
|
||||
updatedAt: e.updatedAt
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
if (payloads.photos.length > 0) {
|
||||
await db.photos.bulkPut(
|
||||
payloads.photos.map((p) => ({
|
||||
payloadId: p.payloadId,
|
||||
entryId: p.entryId,
|
||||
logbookId,
|
||||
encryptedData: p.encryptedData,
|
||||
iv: p.iv,
|
||||
tag: p.tag,
|
||||
caption: '',
|
||||
updatedAt: p.updatedAt
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
if (payloads.gpsTracks.length > 0) {
|
||||
await db.gpsTracks.bulkPut(
|
||||
payloads.gpsTracks.map((t) => ({
|
||||
entryId: t.entryId,
|
||||
logbookId,
|
||||
encryptedData: t.encryptedData,
|
||||
iv: t.iv,
|
||||
tag: t.tag,
|
||||
updatedAt: t.updatedAt
|
||||
}))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function exportLogbookBackup(
|
||||
logbookId: string,
|
||||
passphrase: string
|
||||
): Promise<{ blob: Blob; filename: string; backup: LogbookBackupFile }> {
|
||||
if (!passphrase.trim() || passphrase.length < 8) {
|
||||
throw new Error('BACKUP_PASSPHRASE_TOO_SHORT')
|
||||
}
|
||||
|
||||
const logbook = await db.logbooks.get(logbookId)
|
||||
if (!logbook || logbook.isShared === 1) {
|
||||
throw new Error('BACKUP_NOT_OWNER')
|
||||
}
|
||||
|
||||
if (navigator.onLine) {
|
||||
await syncLogbook(logbookId).catch((err) => {
|
||||
console.warn('Pre-backup sync failed, exporting local data:', err)
|
||||
})
|
||||
}
|
||||
|
||||
const logbookKey = (await getLogbookKey(logbookId)) ?? (await ensureLogbookKey(logbookId))
|
||||
const payloads = await collectLogbookPayloads(logbookId)
|
||||
const wrappedKey = await wrapLogbookKey(logbookKey, passphrase)
|
||||
|
||||
const backup: LogbookBackupFile = {
|
||||
format: BACKUP_FORMAT,
|
||||
version: BACKUP_VERSION,
|
||||
exportedAt: new Date().toISOString(),
|
||||
logbook: {
|
||||
id: logbook.id,
|
||||
encryptedTitle: logbook.encryptedTitle,
|
||||
updatedAt: logbook.updatedAt,
|
||||
isDemo: logbook.isDemo === 1
|
||||
},
|
||||
logbookKey: wrappedKey,
|
||||
payloads,
|
||||
counts: {
|
||||
entries: payloads.entries.length,
|
||||
photos: payloads.photos.length,
|
||||
crews: payloads.crews.length,
|
||||
gpsTracks: payloads.gpsTracks.length,
|
||||
hasYacht: !!payloads.yacht,
|
||||
hasDeviation: !!payloads.deviation
|
||||
}
|
||||
}
|
||||
|
||||
const title = await decryptLogbookTitle(logbookId, logbook.encryptedTitle)
|
||||
const safeTitle = title.replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-').slice(0, 40) || 'logbook'
|
||||
const datePart = new Date().toISOString().slice(0, 10)
|
||||
const filename = `${safeTitle}-${datePart}.daagbok.json`
|
||||
const blob = new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' })
|
||||
|
||||
return { blob, filename, backup }
|
||||
}
|
||||
|
||||
export async function parseLogbookBackupFile(file: File): Promise<LogbookBackupFile> {
|
||||
const text = await file.text()
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(text)
|
||||
} catch {
|
||||
throw new Error('BACKUP_INVALID_JSON')
|
||||
}
|
||||
|
||||
if (!isBackupFile(parsed)) {
|
||||
throw new Error('BACKUP_INVALID_FORMAT')
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
export async function previewLogbookBackup(
|
||||
backup: LogbookBackupFile,
|
||||
passphrase: string
|
||||
): Promise<LogbookBackupPreview> {
|
||||
const logbookKey = await unwrapLogbookKey(backup.logbookKey, passphrase)
|
||||
const parsed = JSON.parse(backup.logbook.encryptedTitle)
|
||||
const title = await decryptJson(parsed.ciphertext, parsed.iv, parsed.tag, logbookKey)
|
||||
|
||||
return {
|
||||
title,
|
||||
exportedAt: backup.exportedAt,
|
||||
sourceLogbookId: backup.logbook.id,
|
||||
counts: backup.counts
|
||||
}
|
||||
}
|
||||
|
||||
export interface RestoreLogbookOptions {
|
||||
overwrite?: boolean
|
||||
assignNewId?: boolean
|
||||
}
|
||||
|
||||
export async function restoreLogbookBackup(
|
||||
backup: LogbookBackupFile,
|
||||
passphrase: string,
|
||||
options: RestoreLogbookOptions = {}
|
||||
): Promise<{ logbookId: string; title: string }> {
|
||||
if (!getActiveMasterKey()) {
|
||||
throw new Error('BACKUP_NOT_AUTHENTICATED')
|
||||
}
|
||||
|
||||
const logbookKey = await unwrapLogbookKey(backup.logbookKey, passphrase)
|
||||
const parsedTitle = JSON.parse(backup.logbook.encryptedTitle)
|
||||
const title = await decryptJson(
|
||||
parsedTitle.ciphertext,
|
||||
parsedTitle.iv,
|
||||
parsedTitle.tag,
|
||||
logbookKey
|
||||
)
|
||||
|
||||
let targetId = backup.logbook.id
|
||||
const existing = await db.logbooks.get(targetId)
|
||||
|
||||
if (existing && !options.overwrite && !options.assignNewId) {
|
||||
throw new Error('BACKUP_ID_CONFLICT')
|
||||
}
|
||||
|
||||
if (existing && options.overwrite) {
|
||||
await deleteLocalLogbookCache(targetId)
|
||||
}
|
||||
|
||||
if (options.assignNewId || (existing && !options.overwrite)) {
|
||||
targetId = crypto.randomUUID()
|
||||
}
|
||||
|
||||
const prepared = targetId === backup.logbook.id ? backup : remapBackup(backup, targetId)
|
||||
|
||||
await writeBackupToDexie(targetId, prepared, logbookKey)
|
||||
await queueRestoredLogbookForSync(
|
||||
targetId,
|
||||
prepared.logbook.encryptedTitle,
|
||||
logbookKey,
|
||||
prepared.payloads
|
||||
)
|
||||
|
||||
if (navigator.onLine) {
|
||||
await syncLogbook(targetId).catch((err) => {
|
||||
console.warn('Post-restore sync failed, data saved locally:', err)
|
||||
})
|
||||
}
|
||||
|
||||
return { logbookId: targetId, title }
|
||||
}
|
||||
|
||||
export function downloadBackupBlob(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = filename
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
@@ -3,12 +3,13 @@ import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { getLogbookKey } from './logbookKeys.js'
|
||||
import { decryptJson } from './crypto.js'
|
||||
import { isSignatureImage, isPasskeySignature } from '../utils/signatures.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> {
|
||||
@@ -132,7 +133,7 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
|
||||
// Draw Data Rows
|
||||
const events = entry.events || [];
|
||||
const maxRows = 16;
|
||||
const sortedEvents = [...events].sort((a: any, b: any) => (a.time || '').localeCompare(b.time || ''));
|
||||
const sortedEvents = sortLogEventsByTime(events);
|
||||
|
||||
doc.setFont('Helvetica', 'normal');
|
||||
|
||||
@@ -255,8 +256,16 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
|
||||
const crewDate = formatPasskeySignDate(entry.signCrew.signedAt);
|
||||
doc.text(`Passkey: ${entry.signCrew.username}`, sigX + 80.5, sigY + 9);
|
||||
doc.text(crewDate, sigX + 80.5, sigY + 13.5);
|
||||
} else if (isSignatureImage(entry.signCrew)) {
|
||||
doc.addImage(entry.signCrew, 'PNG', sigX + 80.5, sigY + 6, 72, 14)
|
||||
} else if (isClassicSignature(entry.signCrew)) {
|
||||
doc.setFont('Helvetica', 'normal');
|
||||
const crewDate = formatPasskeySignDate(entry.signCrew.signedAt);
|
||||
doc.text(entry.signCrew.username, sigX + 80.5, sigY + 9);
|
||||
doc.text(crewDate, sigX + 80.5, sigY + 13.5);
|
||||
if (isSignatureImage(entry.signCrew.payload)) {
|
||||
doc.addImage(entry.signCrew.payload, 'PNG', sigX + 80.5, sigY + 6, 72, 14)
|
||||
}
|
||||
} else if (isSignatureImage(getSignaturePayload(entry.signCrew))) {
|
||||
doc.addImage(getSignaturePayload(entry.signCrew), 'PNG', sigX + 80.5, sigY + 6, 72, 14)
|
||||
} else {
|
||||
doc.setFont('Helvetica', 'normal');
|
||||
doc.text(String(entry.signCrew || '—').toUpperCase(), sigX + 80.5, sigY + 11.2);
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { apiFetch, apiJson } from './api.js'
|
||||
|
||||
const API_BASE = '/api/push'
|
||||
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||
const raw = atob(base64)
|
||||
const output = new Uint8Array(raw.length)
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
output[i] = raw.charCodeAt(i)
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
export function isPushSupported(): boolean {
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
'serviceWorker' in navigator &&
|
||||
'PushManager' in window &&
|
||||
'Notification' in window
|
||||
)
|
||||
}
|
||||
|
||||
export function getNotificationPermission(): NotificationPermission | 'unsupported' {
|
||||
if (!isPushSupported()) return 'unsupported'
|
||||
return Notification.permission
|
||||
}
|
||||
|
||||
async function fetchVapidPublicKey(): Promise<string | null> {
|
||||
const envKey = import.meta.env.VITE_VAPID_PUBLIC_KEY
|
||||
if (typeof envKey === 'string' && envKey.trim()) {
|
||||
return envKey.trim()
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/vapid-public-key`)
|
||||
if (!res.ok) return null
|
||||
const data = await res.json()
|
||||
return typeof data.publicKey === 'string' ? data.publicKey : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchPushPrefs(): Promise<{ collaboratorChangesEnabled: boolean }> {
|
||||
if (!localStorage.getItem('active_userid')) {
|
||||
return { collaboratorChangesEnabled: false }
|
||||
}
|
||||
|
||||
return apiJson<{ collaboratorChangesEnabled: boolean }>(`${API_BASE}/prefs`)
|
||||
}
|
||||
|
||||
export async function savePushPrefs(collaboratorChangesEnabled: boolean): Promise<void> {
|
||||
if (!localStorage.getItem('active_userid')) throw new Error('Not authenticated')
|
||||
|
||||
await apiJson(`${API_BASE}/prefs`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ collaboratorChangesEnabled })
|
||||
})
|
||||
}
|
||||
|
||||
async function saveSubscriptionToServer(subscription: PushSubscription): Promise<void> {
|
||||
if (!localStorage.getItem('active_userid')) throw new Error('Not authenticated')
|
||||
|
||||
const json = subscription.toJSON()
|
||||
if (!json.endpoint || !json.keys?.p256dh || !json.keys?.auth) {
|
||||
throw new Error('Invalid push subscription')
|
||||
}
|
||||
|
||||
const locale = document.documentElement.lang?.startsWith('en') ? 'en' : 'de'
|
||||
|
||||
await apiJson(`${API_BASE}/subscription`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
endpoint: json.endpoint,
|
||||
keys: json.keys,
|
||||
locale,
|
||||
userAgent: navigator.userAgent
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function subscribeToPush(): Promise<void> {
|
||||
if (!isPushSupported()) {
|
||||
throw new Error('Push notifications are not supported on this device')
|
||||
}
|
||||
|
||||
const permission = await Notification.requestPermission()
|
||||
if (permission !== 'granted') {
|
||||
throw new Error('Notification permission denied')
|
||||
}
|
||||
|
||||
const publicKey = await fetchVapidPublicKey()
|
||||
if (!publicKey) {
|
||||
throw new Error('Push notifications are not configured on this server')
|
||||
}
|
||||
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
let subscription = await registration.pushManager.getSubscription()
|
||||
|
||||
if (!subscription) {
|
||||
const keyBytes = urlBase64ToUint8Array(publicKey)
|
||||
const applicationServerKey = new Uint8Array(keyBytes)
|
||||
subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey
|
||||
})
|
||||
}
|
||||
|
||||
await saveSubscriptionToServer(subscription)
|
||||
}
|
||||
|
||||
export async function unsubscribeFromPush(): Promise<void> {
|
||||
if (!isPushSupported()) return
|
||||
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
const subscription = await registration.pushManager.getSubscription()
|
||||
if (!subscription) return
|
||||
|
||||
const endpoint = subscription.endpoint
|
||||
await subscription.unsubscribe()
|
||||
|
||||
if (localStorage.getItem('active_userid') && endpoint) {
|
||||
await apiFetch(`${API_BASE}/subscription`, {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ endpoint })
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-register subscription when prefs are on and permission already granted. */
|
||||
export async function ensurePushSubscriptionIfEnabled(): Promise<void> {
|
||||
if (!isPushSupported() || Notification.permission !== 'granted') return
|
||||
|
||||
const prefs = await fetchPushPrefs()
|
||||
if (!prefs.collaboratorChangesEnabled) return
|
||||
|
||||
try {
|
||||
await subscribeToPush()
|
||||
} catch (err) {
|
||||
console.warn('Could not refresh push subscription:', err)
|
||||
}
|
||||
}
|
||||
|
||||
export async function enableCollaboratorChangePush(): Promise<void> {
|
||||
await subscribeToPush()
|
||||
await savePushPrefs(true)
|
||||
}
|
||||
|
||||
export async function disableCollaboratorChangePush(): Promise<void> {
|
||||
await savePushPrefs(false)
|
||||
await unsubscribeFromPush()
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { getLogbookKey } from './logbookKeys.js'
|
||||
import { decryptJson } from './crypto.js'
|
||||
import { decryptLogbookTitle } from './logbook.js'
|
||||
import { getDecryptedTrack, type TrackWaypoint } from './trackUpload.js'
|
||||
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
|
||||
import type { LogEntryPayloadInput } from '../utils/logEntryPayload.js'
|
||||
import {
|
||||
parseEventDistanceNm,
|
||||
splitDistanceByPropulsion
|
||||
} from '../utils/propulsionStats.js'
|
||||
import { computeFuelPerMotorHour } from '../utils/fuelStats.js'
|
||||
|
||||
export type DistanceSource = 'gps' | 'events' | 'none'
|
||||
|
||||
export interface TravelDayStats {
|
||||
entryId: string
|
||||
logbookId: string
|
||||
date: string
|
||||
dayOfTravel: string
|
||||
departure: string
|
||||
destination: string
|
||||
distanceNm: number
|
||||
distanceSource: DistanceSource
|
||||
fuelConsumptionL: number
|
||||
freshwaterConsumptionL: number
|
||||
sailDistanceNm: number
|
||||
motorDistanceNm: number
|
||||
unknownPropulsionNm: number
|
||||
motorHours: number
|
||||
fuelPerMotorHourL: number | null
|
||||
hasGpsTrack: boolean
|
||||
}
|
||||
|
||||
export interface TrackSegment {
|
||||
entryId: string
|
||||
dayOfTravel: string
|
||||
label: string
|
||||
waypoints: TrackWaypoint[]
|
||||
colorIndex: number
|
||||
}
|
||||
|
||||
export interface LogbookStatsSummary {
|
||||
logbookId: string
|
||||
title: string
|
||||
travelDays: TravelDayStats[]
|
||||
routePorts: string[]
|
||||
trackSegments: TrackSegment[]
|
||||
totals: StatsTotals
|
||||
}
|
||||
|
||||
export interface AccountStatsSummary {
|
||||
logbooks: LogbookStatsSummary[]
|
||||
totals: StatsTotals
|
||||
}
|
||||
|
||||
export interface StatsTotals {
|
||||
travelDayCount: number
|
||||
daysWithGps: number
|
||||
totalDistanceNm: number
|
||||
sailDistanceNm: number
|
||||
motorDistanceNm: number
|
||||
unknownPropulsionNm: number
|
||||
totalMotorHours: number
|
||||
totalFuelL: number
|
||||
totalFreshwaterL: number
|
||||
avgDistancePerDayNm: number
|
||||
avgMotorHoursPerDay: number
|
||||
avgFuelPerDayL: number
|
||||
avgFreshwaterPerDayL: number
|
||||
fuelPerNmL: number | null
|
||||
fuelPerMotorHourL: number | null
|
||||
}
|
||||
|
||||
const TRACK_COLORS = [
|
||||
'#3b82f6',
|
||||
'#10b981',
|
||||
'#f59e0b',
|
||||
'#8b5cf6',
|
||||
'#ec4899',
|
||||
'#06b6d4',
|
||||
'#ef4444',
|
||||
'#84cc16'
|
||||
]
|
||||
|
||||
function resolveDistanceNm(payload: LogEntryPayloadInput): { distanceNm: number; distanceSource: DistanceSource } {
|
||||
const gpsDistance = Number(payload.trackDistanceNm) || 0
|
||||
if (gpsDistance > 0) {
|
||||
return { distanceNm: gpsDistance, distanceSource: 'gps' }
|
||||
}
|
||||
|
||||
const eventSum = (payload.events || []).reduce(
|
||||
(sum, event) => sum + parseEventDistanceNm(event.distance),
|
||||
0
|
||||
)
|
||||
if (eventSum > 0) {
|
||||
return { distanceNm: Number(eventSum.toFixed(2)), distanceSource: 'events' }
|
||||
}
|
||||
|
||||
return { distanceNm: 0, distanceSource: 'none' }
|
||||
}
|
||||
|
||||
function buildTotals(days: TravelDayStats[]): StatsTotals {
|
||||
const travelDayCount = days.length
|
||||
const daysWithGps = days.filter((d) => d.hasGpsTrack).length
|
||||
const totalDistanceNm = days.reduce((sum, d) => sum + d.distanceNm, 0)
|
||||
const sailDistanceNm = days.reduce((sum, d) => sum + d.sailDistanceNm, 0)
|
||||
const motorDistanceNm = days.reduce((sum, d) => sum + d.motorDistanceNm, 0)
|
||||
const unknownPropulsionNm = days.reduce((sum, d) => sum + d.unknownPropulsionNm, 0)
|
||||
const totalMotorHours = days.reduce((sum, d) => sum + d.motorHours, 0)
|
||||
const totalFuelL = days.reduce((sum, d) => sum + d.fuelConsumptionL, 0)
|
||||
const totalFreshwaterL = days.reduce((sum, d) => sum + d.freshwaterConsumptionL, 0)
|
||||
|
||||
return {
|
||||
travelDayCount,
|
||||
daysWithGps,
|
||||
totalDistanceNm: Number(totalDistanceNm.toFixed(2)),
|
||||
sailDistanceNm: Number(sailDistanceNm.toFixed(2)),
|
||||
motorDistanceNm: Number(motorDistanceNm.toFixed(2)),
|
||||
unknownPropulsionNm: Number(unknownPropulsionNm.toFixed(2)),
|
||||
totalMotorHours: Number(totalMotorHours.toFixed(1)),
|
||||
totalFuelL: Number(totalFuelL.toFixed(1)),
|
||||
totalFreshwaterL: Number(totalFreshwaterL.toFixed(1)),
|
||||
avgDistancePerDayNm:
|
||||
travelDayCount > 0 ? Number((totalDistanceNm / travelDayCount).toFixed(2)) : 0,
|
||||
avgMotorHoursPerDay:
|
||||
travelDayCount > 0 ? Number((totalMotorHours / travelDayCount).toFixed(1)) : 0,
|
||||
avgFuelPerDayL:
|
||||
travelDayCount > 0 ? Number((totalFuelL / travelDayCount).toFixed(1)) : 0,
|
||||
avgFreshwaterPerDayL:
|
||||
travelDayCount > 0 ? Number((totalFreshwaterL / travelDayCount).toFixed(1)) : 0,
|
||||
fuelPerNmL:
|
||||
totalDistanceNm > 0 && totalFuelL > 0
|
||||
? Number((totalFuelL / totalDistanceNm).toFixed(2))
|
||||
: null,
|
||||
fuelPerMotorHourL: computeFuelPerMotorHour(totalFuelL, totalMotorHours)
|
||||
}
|
||||
}
|
||||
|
||||
export function buildRoutePorts(days: TravelDayStats[]): string[] {
|
||||
const ports: string[] = []
|
||||
for (const day of days) {
|
||||
const dep = day.departure.trim()
|
||||
const dest = day.destination.trim()
|
||||
if (dep && (ports.length === 0 || ports[ports.length - 1] !== dep)) {
|
||||
ports.push(dep)
|
||||
}
|
||||
if (dest && (ports.length === 0 || ports[ports.length - 1] !== dest)) {
|
||||
ports.push(dest)
|
||||
}
|
||||
}
|
||||
return ports
|
||||
}
|
||||
|
||||
async function loadTravelDaysForLogbook(
|
||||
logbookId: string,
|
||||
includeTracks: boolean
|
||||
): Promise<{ days: TravelDayStats[]; trackSegments: TrackSegment[] }> {
|
||||
const masterKey = (await getLogbookKey(logbookId)) || getActiveMasterKey()
|
||||
if (!masterKey) {
|
||||
throw new Error('Encryption key not found. Please log in.')
|
||||
}
|
||||
|
||||
const localEntries = await db.entries.where({ logbookId }).toArray()
|
||||
const days: TravelDayStats[] = []
|
||||
const trackSegments: TrackSegment[] = []
|
||||
|
||||
for (const entry of localEntries) {
|
||||
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
|
||||
if (!decrypted) continue
|
||||
|
||||
const payload = decrypted as LogEntryPayloadInput
|
||||
const { distanceNm, distanceSource } = resolveDistanceNm(payload)
|
||||
const propulsion = splitDistanceByPropulsion(distanceNm, payload.events || [])
|
||||
|
||||
let hasGpsTrack = false
|
||||
if (includeTracks) {
|
||||
const track = await getDecryptedTrack(entry.payloadId)
|
||||
if (track && track.waypoints.length >= 2) {
|
||||
hasGpsTrack = true
|
||||
trackSegments.push({
|
||||
entryId: entry.payloadId,
|
||||
dayOfTravel: payload.dayOfTravel || '',
|
||||
label: payload.dayOfTravel || '?',
|
||||
waypoints: track.waypoints,
|
||||
colorIndex: trackSegments.length % TRACK_COLORS.length
|
||||
})
|
||||
}
|
||||
} else {
|
||||
hasGpsTrack = !!(await db.gpsTracks.get(entry.payloadId))
|
||||
}
|
||||
|
||||
const fuelConsumptionL = Number(payload.fuel?.consumption) || 0
|
||||
const motorHours = Number(payload.motorHours) || 0
|
||||
|
||||
days.push({
|
||||
entryId: entry.payloadId,
|
||||
logbookId,
|
||||
date: payload.date || '',
|
||||
dayOfTravel: payload.dayOfTravel || '',
|
||||
departure: payload.departure || '',
|
||||
destination: payload.destination || '',
|
||||
distanceNm,
|
||||
distanceSource,
|
||||
fuelConsumptionL,
|
||||
freshwaterConsumptionL: Number(payload.freshwater?.consumption) || 0,
|
||||
sailDistanceNm: propulsion.sailDistanceNm,
|
||||
motorDistanceNm: propulsion.motorDistanceNm,
|
||||
unknownPropulsionNm: propulsion.unknownPropulsionNm,
|
||||
motorHours,
|
||||
fuelPerMotorHourL: computeFuelPerMotorHour(fuelConsumptionL, motorHours),
|
||||
hasGpsTrack
|
||||
})
|
||||
}
|
||||
|
||||
days.sort(compareTravelDaysChronological)
|
||||
trackSegments.sort((a, b) => Number(a.dayOfTravel) - Number(b.dayOfTravel))
|
||||
|
||||
return { days, trackSegments }
|
||||
}
|
||||
|
||||
export async function loadLogbookStats(
|
||||
logbookId: string,
|
||||
title: string,
|
||||
includeTracks = true
|
||||
): Promise<LogbookStatsSummary> {
|
||||
const { days, trackSegments } = await loadTravelDaysForLogbook(logbookId, includeTracks)
|
||||
return {
|
||||
logbookId,
|
||||
title,
|
||||
travelDays: days,
|
||||
routePorts: buildRoutePorts(days),
|
||||
trackSegments,
|
||||
totals: buildTotals(days)
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadAccountStats(includeTracks = false): Promise<AccountStatsSummary> {
|
||||
const logbooks = await db.logbooks.toArray()
|
||||
const summaries: LogbookStatsSummary[] = []
|
||||
|
||||
for (const lb of logbooks) {
|
||||
const title = await decryptLogbookTitle(lb.id, lb.encryptedTitle)
|
||||
summaries.push(await loadLogbookStats(lb.id, title, includeTracks))
|
||||
}
|
||||
|
||||
summaries.sort((a, b) => a.title.localeCompare(b.title, undefined, { sensitivity: 'base' }))
|
||||
|
||||
const allDays = summaries.flatMap((s) => s.travelDays)
|
||||
return {
|
||||
logbooks: summaries,
|
||||
totals: buildTotals(allDays)
|
||||
}
|
||||
}
|
||||
|
||||
export function getTrackColor(index: number): string {
|
||||
return TRACK_COLORS[index % TRACK_COLORS.length]
|
||||
}
|
||||
|
||||
export function formatNm(value: number): string {
|
||||
return value.toFixed(2)
|
||||
}
|
||||
|
||||
export function formatLiters(value: number): string {
|
||||
return Number.isInteger(value) ? String(value) : value.toFixed(1)
|
||||
}
|
||||
|
||||
export function formatHours(value: number): string {
|
||||
return Number.isInteger(value) ? String(value) : value.toFixed(1)
|
||||
}
|
||||
+174
-27
@@ -1,8 +1,11 @@
|
||||
import { db } from './db.js'
|
||||
import { db, type SyncQueueItem } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { apiFetch } from './api.js'
|
||||
import { getLogbookAccess } from './logbookAccess.js'
|
||||
|
||||
const API_BASE = '/api/sync'
|
||||
const syncingLogbooks = new Set<string>()
|
||||
const pendingResync = new Set<string>()
|
||||
|
||||
let isSyncing = false
|
||||
const listeners = new Set<(syncing: boolean) => void>()
|
||||
@@ -27,22 +30,135 @@ function isNewer(timeA: string | Date, timeB: string | Date): boolean {
|
||||
return new Date(timeA).getTime() > new Date(timeB).getTime()
|
||||
}
|
||||
|
||||
function entityKey(item: SyncQueueItem): string {
|
||||
return `${item.type}:${item.payloadId}`
|
||||
}
|
||||
|
||||
function latestQueueItem(items: SyncQueueItem[]): SyncQueueItem {
|
||||
return items.reduce((a, b) => ((a.id ?? 0) > (b.id ?? 0) ? a : b))
|
||||
}
|
||||
|
||||
async function entityExistsLocally(item: SyncQueueItem): Promise<boolean> {
|
||||
switch (item.type) {
|
||||
case 'logbook':
|
||||
return !!(await db.logbooks.get(item.payloadId))
|
||||
case 'yacht':
|
||||
return !!(await db.yachts.get(item.logbookId))
|
||||
case 'deviation':
|
||||
return !!(await db.deviations.get(item.logbookId))
|
||||
case 'crew':
|
||||
return !!(await db.crews.get(item.payloadId))
|
||||
case 'entry':
|
||||
return !!(await db.entries.get(item.payloadId))
|
||||
case 'photo':
|
||||
return !!(await db.photos.get(item.payloadId))
|
||||
case 'gpsTrack':
|
||||
return !!(await db.gpsTracks.get(item.payloadId))
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Pick one queue entry per entity. If the record still exists locally, the latest
|
||||
// action wins (supports recreate-after-delete). If it was removed locally, a delete
|
||||
// wins over stale upserts with higher IDs; orphaned upserts are dropped entirely.
|
||||
async function resolveCoalescedItem(group: SyncQueueItem[]): Promise<SyncQueueItem | null> {
|
||||
const exists = await entityExistsLocally(group[0])
|
||||
if (exists) {
|
||||
return latestQueueItem(group)
|
||||
}
|
||||
|
||||
const deletes = group.filter((item) => item.action === 'delete')
|
||||
if (deletes.length > 0) {
|
||||
return latestQueueItem(deletes)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function coalesceSyncQueue(logbookId: string): Promise<SyncQueueItem[]> {
|
||||
const pending = await db.syncQueue.where({ logbookId }).toArray()
|
||||
if (pending.length <= 1) return pending
|
||||
|
||||
const byEntity = new Map<string, SyncQueueItem[]>()
|
||||
for (const item of pending) {
|
||||
const key = entityKey(item)
|
||||
const group = byEntity.get(key)
|
||||
if (group) group.push(item)
|
||||
else byEntity.set(key, [item])
|
||||
}
|
||||
|
||||
const kept: SyncQueueItem[] = []
|
||||
const staleIds: number[] = []
|
||||
|
||||
for (const group of byEntity.values()) {
|
||||
const winner = await resolveCoalescedItem(group)
|
||||
|
||||
if (winner) {
|
||||
kept.push(winner)
|
||||
for (const item of group) {
|
||||
if (item.id !== undefined && item.id !== winner.id) {
|
||||
staleIds.push(item.id)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const item of group) {
|
||||
if (item.id !== undefined) {
|
||||
staleIds.push(item.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (staleIds.length > 0) {
|
||||
await db.syncQueue.bulkDelete(staleIds)
|
||||
}
|
||||
|
||||
return kept.sort((a, b) => (a.id ?? 0) - (b.id ?? 0))
|
||||
}
|
||||
|
||||
function scheduleResync(logbookId: string) {
|
||||
if (pendingResync.has(logbookId)) return
|
||||
pendingResync.add(logbookId)
|
||||
queueMicrotask(() => {
|
||||
pendingResync.delete(logbookId)
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Deferred sync failed:', err))
|
||||
})
|
||||
}
|
||||
|
||||
type LogbookPushAccess = 'OWNER' | 'WRITE' | 'READ' | 'UNKNOWN'
|
||||
|
||||
async function resolveLogbookPushAccess(logbookId: string): Promise<LogbookPushAccess> {
|
||||
const access = await getLogbookAccess(logbookId)
|
||||
if (access) {
|
||||
return access.isOwner || access.role === 'OWNER' ? 'OWNER' : access.role
|
||||
}
|
||||
|
||||
const local = await db.logbooks.get(logbookId)
|
||||
if (local?.isShared !== 1) return 'OWNER'
|
||||
if (local.collaborationRole === 'READ') return 'READ'
|
||||
if (local.collaborationRole === 'WRITE') return 'WRITE'
|
||||
return 'UNKNOWN'
|
||||
}
|
||||
|
||||
// Push local sync queue items to the server
|
||||
async function pushChanges(logbookId: string): Promise<boolean> {
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId) return false
|
||||
if (!getActiveMasterKey() || !localStorage.getItem('active_userid')) return false
|
||||
|
||||
// Fetch all pending queue items for this logbook
|
||||
const pending = await db.syncQueue.where({ logbookId }).toArray()
|
||||
const pending = await coalesceSyncQueue(logbookId)
|
||||
if (pending.length === 0) return true
|
||||
|
||||
const pushAccess = await resolveLogbookPushAccess(logbookId)
|
||||
if (pushAccess === 'READ' || pushAccess === 'UNKNOWN') {
|
||||
console.warn(
|
||||
`[sync] Skipping push for logbook ${logbookId} (${pushAccess}); ${pending.length} queue item(s) retained`
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/push`, {
|
||||
const response = await apiFetch(`${API_BASE}/push`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-User-Id': userId
|
||||
},
|
||||
body: JSON.stringify({ items: pending })
|
||||
})
|
||||
|
||||
@@ -53,13 +169,14 @@ async function pushChanges(logbookId: string): Promise<boolean> {
|
||||
|
||||
const { results } = await response.json()
|
||||
|
||||
// Process results
|
||||
for (const res of results) {
|
||||
// Match results by index — payloadId alone is not unique in the queue
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const res = results[i]
|
||||
const queueItem = pending[i]
|
||||
if (!queueItem) continue
|
||||
|
||||
if (res.status === 'success' || res.status === 'conflict') {
|
||||
// Find matching queue item
|
||||
const queueItem = pending.find((item) => item.payloadId === res.payloadId)
|
||||
if (queueItem && queueItem.id !== undefined) {
|
||||
// Delete from sync queue
|
||||
if (queueItem.id !== undefined) {
|
||||
await db.syncQueue.delete(queueItem.id)
|
||||
}
|
||||
} else {
|
||||
@@ -73,17 +190,28 @@ async function pushChanges(logbookId: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
async function flushPushQueue(logbookId: string): Promise<boolean> {
|
||||
let ok = true
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
const before = await db.syncQueue.where({ logbookId }).count()
|
||||
if (before === 0) return ok
|
||||
|
||||
const pushed = await pushChanges(logbookId)
|
||||
ok = ok && pushed
|
||||
|
||||
const after = await db.syncQueue.where({ logbookId }).count()
|
||||
if (after === 0 || after === before) break
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
// Pull updates from the server and apply last-write-wins
|
||||
async function pullChanges(logbookId: string): Promise<boolean> {
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (!userId) return false
|
||||
if (!localStorage.getItem('active_userid')) return false
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/pull?logbookId=${logbookId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-User-Id': userId
|
||||
}
|
||||
const response = await apiFetch(`${API_BASE}/pull?logbookId=${logbookId}`, {
|
||||
method: 'GET'
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -266,14 +394,20 @@ export async function syncLogbook(logbookId: string): Promise<boolean> {
|
||||
const masterKey = getActiveMasterKey()
|
||||
if (!masterKey) return false
|
||||
|
||||
if (syncingLogbooks.has(logbookId)) return false
|
||||
if (syncingLogbooks.has(logbookId)) {
|
||||
scheduleResync(logbookId)
|
||||
return false
|
||||
}
|
||||
|
||||
syncingLogbooks.add(logbookId)
|
||||
setSyncing(true)
|
||||
|
||||
try {
|
||||
const pushed = await pushChanges(logbookId)
|
||||
const pushed = await flushPushQueue(logbookId)
|
||||
const pulled = await pullChanges(logbookId)
|
||||
return pushed && pulled;
|
||||
// Push again in case pull surfaced nothing but queue grew during pull
|
||||
const pushedAfterPull = await flushPushQueue(logbookId)
|
||||
return pushed && pulled && pushedAfterPull
|
||||
} finally {
|
||||
syncingLogbooks.delete(logbookId)
|
||||
setSyncing(syncingLogbooks.size > 0)
|
||||
@@ -296,6 +430,19 @@ export async function syncAllLogbooks(): Promise<void> {
|
||||
for (const lb of logbooks) {
|
||||
await syncLogbook(lb.id)
|
||||
}
|
||||
|
||||
// 3. Clean up orphaned queue items for logbooks no longer in db.logbooks.
|
||||
// Re-read logbooks so any logbooks created during step 2 are included.
|
||||
const freshLogbooks = await db.logbooks.toArray()
|
||||
const freshKnownIds = new Set(freshLogbooks.map((l) => l.id))
|
||||
const currentQueue = await db.syncQueue.toArray()
|
||||
const orphanedIds = currentQueue
|
||||
.filter((i) => !freshKnownIds.has(i.logbookId))
|
||||
.map((i) => i.id!)
|
||||
.filter(Boolean)
|
||||
if (orphanedIds.length > 0) {
|
||||
await db.syncQueue.bulkDelete(orphanedIds)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error synchronizing all logbooks:', error)
|
||||
} finally {
|
||||
@@ -304,7 +451,7 @@ export async function syncAllLogbooks(): Promise<void> {
|
||||
}
|
||||
|
||||
// Setup background sync intervals
|
||||
let syncIntervalId: any = null
|
||||
let syncIntervalId: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
export function startBackgroundSync(intervalMs = 30000) {
|
||||
if (syncIntervalId) clearInterval(syncIntervalId)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getColorSchemePreference,
|
||||
getOwmApiKey,
|
||||
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('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,86 @@
|
||||
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 ?? getActiveUserId()
|
||||
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) ?? ''
|
||||
}
|
||||
|
||||
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,46 @@
|
||||
import { apiFetch } from './api.js'
|
||||
import { getOwmApiKey } from './userPreferences.js'
|
||||
|
||||
export class WeatherApiError extends Error {
|
||||
code: 'NO_KEY' | 'REQUEST_FAILED'
|
||||
|
||||
constructor(message: string, code: 'NO_KEY' | 'REQUEST_FAILED' = 'REQUEST_FAILED') {
|
||||
super(message)
|
||||
this.name = 'WeatherApiError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchOpenWeatherCurrent(params: {
|
||||
lat?: string
|
||||
lon?: string
|
||||
q?: string
|
||||
}): Promise<Record<string, unknown>> {
|
||||
const searchParams = new URLSearchParams()
|
||||
|
||||
if (params.lat && params.lon) {
|
||||
searchParams.set('lat', params.lat)
|
||||
searchParams.set('lon', params.lon)
|
||||
} else if (params.q?.trim()) {
|
||||
searchParams.set('q', params.q.trim())
|
||||
} else {
|
||||
throw new WeatherApiError('lat/lon or location query required')
|
||||
}
|
||||
|
||||
const userKey = getOwmApiKey().trim()
|
||||
const headers: Record<string, string> = {}
|
||||
if (userKey) headers['X-OWM-Api-Key'] = userKey
|
||||
|
||||
const res = await apiFetch(`/api/weather/current?${searchParams.toString()}`, { headers })
|
||||
|
||||
if (res.status === 503) {
|
||||
throw new WeatherApiError('No OpenWeatherMap API key configured', 'NO_KEY')
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
throw new WeatherApiError('Weather API rejected the request')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/// <reference lib="webworker" />
|
||||
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching'
|
||||
|
||||
declare let self: ServiceWorkerGlobalScope
|
||||
|
||||
precacheAndRoute(self.__WB_MANIFEST)
|
||||
cleanupOutdatedCaches()
|
||||
|
||||
interface PushPayload {
|
||||
title?: string
|
||||
body?: string
|
||||
tag?: string
|
||||
renotify?: boolean
|
||||
data?: {
|
||||
url?: string
|
||||
logbookId?: string
|
||||
changeCount?: number
|
||||
}
|
||||
}
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
let payload: PushPayload = {}
|
||||
try {
|
||||
payload = event.data?.json() ?? {}
|
||||
} catch {
|
||||
payload = { body: event.data?.text() ?? '' }
|
||||
}
|
||||
|
||||
const title = payload.title ?? 'Kapteins Daagbok'
|
||||
const body = payload.body ?? ''
|
||||
const data = payload.data ?? {}
|
||||
|
||||
await self.registration.showNotification(title, {
|
||||
body,
|
||||
tag: payload.tag,
|
||||
icon: '/logo.png',
|
||||
badge: '/logo.png',
|
||||
data
|
||||
})
|
||||
})()
|
||||
)
|
||||
})
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close()
|
||||
const data = (event.notification.data ?? {}) as PushPayload['data']
|
||||
const targetPath = data?.url ?? '/'
|
||||
const targetUrl = new URL(targetPath, self.location.origin).href
|
||||
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const windowClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
includeUncontrolled: true
|
||||
})
|
||||
|
||||
for (const client of windowClients) {
|
||||
if ('focus' in client) {
|
||||
await client.focus()
|
||||
client.postMessage({
|
||||
type: 'OPEN_LOGBOOK',
|
||||
logbookId: data?.logbookId
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await self.clients.openWindow(targetUrl)
|
||||
})()
|
||||
)
|
||||
})
|
||||
@@ -11,5 +11,16 @@ export interface PasskeySignature {
|
||||
clientVerified: boolean
|
||||
}
|
||||
|
||||
/** Legacy: PNG data URL oder getippter Name */
|
||||
export type SignatureValue = string | PasskeySignature
|
||||
/** Klassische Unterschrift mit Benutzer-Zuordnung (Crew) */
|
||||
export interface ClassicSignature {
|
||||
kind: 'classic'
|
||||
version: 1
|
||||
role: 'skipper' | 'crew'
|
||||
userId: string
|
||||
username: string
|
||||
signedAt: string
|
||||
payload: string
|
||||
}
|
||||
|
||||
/** Legacy: PNG data URL oder getippter Name; oder strukturierte Signaturen */
|
||||
export type SignatureValue = string | PasskeySignature | ClassicSignature
|
||||
|
||||
@@ -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,37 @@
|
||||
/** BCP 47 locales that use 24-hour clock for Intl formatting. */
|
||||
export function resolveIntlLocale(language?: string): string {
|
||||
const lng = (language ?? 'en').toLowerCase()
|
||||
return lng.startsWith('de') ? 'de-DE' : 'en-GB'
|
||||
}
|
||||
|
||||
const APP_DATE_TIME_OPTIONS: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
}
|
||||
|
||||
const APP_TIME_OPTIONS: Intl.DateTimeFormatOptions = {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
}
|
||||
|
||||
function toDate(value: Date | string | number): Date | null {
|
||||
const date = value instanceof Date ? value : new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
export function formatAppDateTime(value: Date | string | number, language?: string): string {
|
||||
const date = toDate(value)
|
||||
if (!date) return String(value)
|
||||
return date.toLocaleString(resolveIntlLocale(language), APP_DATE_TIME_OPTIONS)
|
||||
}
|
||||
|
||||
export function formatAppTime(value: Date | string | number, language?: string): string {
|
||||
const date = toDate(value)
|
||||
if (!date) return String(value)
|
||||
return date.toLocaleTimeString(resolveIntlLocale(language), APP_TIME_OPTIONS)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/** Liters per motor hour from daily fuel consumption and motor hours. */
|
||||
export function computeFuelPerMotorHour(
|
||||
fuelConsumptionL: number,
|
||||
motorHours: number
|
||||
): number | null {
|
||||
if (motorHours <= 0) return null
|
||||
return Number((fuelConsumptionL / motorHours).toFixed(2))
|
||||
}
|
||||
|
||||
export function formatFuelPerMotorHour(value: number | null | undefined): string {
|
||||
if (value == null) return '—'
|
||||
return Number.isInteger(value) ? String(value) : value.toFixed(2)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { i18n as I18nInstance } from 'i18next'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { resolveIntlLocale } from './dateTimeFormat.js'
|
||||
import { initSeo, normalizeSeoLang, updatePageSeo } from './seo.js'
|
||||
|
||||
const HTML_LANG = /^de|en$/
|
||||
|
||||
function createMockI18n(language: string): I18nInstance {
|
||||
return {
|
||||
isInitialized: true,
|
||||
language,
|
||||
t: (key: string) => key,
|
||||
on: vi.fn()
|
||||
} as unknown as I18nInstance
|
||||
}
|
||||
|
||||
describe('normalizeSeoLang', () => {
|
||||
it.each([
|
||||
['de', 'de'],
|
||||
['de-DE', 'de'],
|
||||
['en', 'en'],
|
||||
['en-US', 'en'],
|
||||
['en-GB', 'en']
|
||||
] as const)('maps %s to short code %s', (input, expected) => {
|
||||
expect(normalizeSeoLang(input)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('updatePageSeo html lang', () => {
|
||||
beforeEach(() => {
|
||||
document.documentElement.lang = 'de'
|
||||
window.history.replaceState({}, '', '/')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['de', 'de'],
|
||||
['en', 'en'],
|
||||
['en-GB', 'en']
|
||||
] as const)('sets html lang to %s when i18n language is %s', (i18nLanguage, expectedLang) => {
|
||||
initSeo(createMockI18n(i18nLanguage))
|
||||
updatePageSeo()
|
||||
|
||||
expect(document.documentElement.lang).toBe(expectedLang)
|
||||
expect(document.documentElement.lang).toMatch(HTML_LANG)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveIntlLocale', () => {
|
||||
it('uses full BCP 47 tags for Intl formatting only', () => {
|
||||
expect(resolveIntlLocale('de')).toBe('de-DE')
|
||||
expect(resolveIntlLocale('en')).toBe('en-GB')
|
||||
})
|
||||
|
||||
it('does not reuse Intl locale tags for html lang', () => {
|
||||
const intlLocale = resolveIntlLocale('en')
|
||||
const htmlLang = normalizeSeoLang('en')
|
||||
|
||||
expect(intlLocale).toBe('en-GB')
|
||||
expect(htmlLang).toBe('en')
|
||||
expect(htmlLang).not.toBe(intlLocale)
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
normalizeCourseAngleString,
|
||||
normalizeWindDirectionString
|
||||
} from './courseAngle.js'
|
||||
|
||||
export interface LogEventPayload {
|
||||
time: string
|
||||
mgk: string
|
||||
@@ -17,6 +22,100 @@ export interface LogEventPayload {
|
||||
remarks: string
|
||||
}
|
||||
|
||||
/** Local time as HH:MM (24-hour). */
|
||||
export function currentLocalTimeHHMM(date: Date = new Date()): string {
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${hours}:${minutes}`
|
||||
}
|
||||
|
||||
/** Parse 24h or 12h (AM/PM) time strings to HH:MM. */
|
||||
export function parseTimeToHHMM(value: string): string | null {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
|
||||
const amPm = trimmed.match(/^(\d{1,2}):(\d{2})(?::\d{2})?\s*(AM|PM)$/i)
|
||||
if (amPm) {
|
||||
let hours = parseInt(amPm[1], 10)
|
||||
const minutes = parseInt(amPm[2], 10)
|
||||
const isPm = amPm[3].toUpperCase() === 'PM'
|
||||
if (hours < 1 || hours > 12 || minutes < 0 || minutes > 59) return null
|
||||
if (hours === 12) hours = isPm ? 12 : 0
|
||||
else if (isPm) hours += 12
|
||||
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const h24 = trimmed.match(/^(\d{1,2}):(\d{2})(?::\d{2})?$/)
|
||||
if (h24) {
|
||||
const hours = parseInt(h24[1], 10)
|
||||
const minutes = parseInt(h24[2], 10)
|
||||
if (hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59) {
|
||||
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function isValidTimeHHMM(value: string): boolean {
|
||||
return parseTimeToHHMM(value) !== null
|
||||
}
|
||||
|
||||
export function splitTimeHHMM(value: string): { hours: string; minutes: string } {
|
||||
const parsed = parseTimeToHHMM(value) ?? currentLocalTimeHHMM()
|
||||
return { hours: parsed.slice(0, 2), minutes: parsed.slice(3, 5) }
|
||||
}
|
||||
|
||||
export function joinTimeHHMM(hours: string, minutes: string): string {
|
||||
const h = Math.min(23, Math.max(0, parseInt(hours, 10) || 0))
|
||||
const m = Math.min(59, Math.max(0, parseInt(minutes, 10) || 0))
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const LOG_EVENT_FIELDS: (keyof LogEventPayload)[] = [
|
||||
'time', 'mgk', 'rwk', 'windPressure', 'windDirection', 'windStrength', 'seaState',
|
||||
'weatherIcon', 'current', 'heel', 'sailsOrMotor', 'logReading', 'distance',
|
||||
'gpsLat', 'gpsLng', 'remarks'
|
||||
]
|
||||
|
||||
/** Normalize partial/legacy events so all fields are strings (safe for form + save). */
|
||||
export function normalizeLogEvent(event: Partial<LogEventPayload> | Record<string, unknown>): LogEventPayload {
|
||||
const e = event as Record<string, unknown>
|
||||
const timeRaw = String(e.time ?? '').trim()
|
||||
const normalized: LogEventPayload = {
|
||||
time: parseTimeToHHMM(timeRaw) ?? (timeRaw.length >= 5 ? timeRaw.slice(0, 5) : timeRaw),
|
||||
mgk: normalizeCourseAngleString(String(e.mgk ?? ''), { allowEmpty: true }),
|
||||
rwk: normalizeCourseAngleString(String(e.rwk ?? ''), { allowEmpty: true }),
|
||||
windPressure: '',
|
||||
windDirection: normalizeWindDirectionString(String(e.windDirection ?? '')),
|
||||
windStrength: '',
|
||||
seaState: '',
|
||||
weatherIcon: '',
|
||||
current: '',
|
||||
heel: '',
|
||||
sailsOrMotor: '',
|
||||
logReading: '',
|
||||
distance: '',
|
||||
gpsLat: '',
|
||||
gpsLng: '',
|
||||
remarks: ''
|
||||
}
|
||||
for (const key of LOG_EVENT_FIELDS) {
|
||||
if (key === 'time' || key === 'mgk' || key === 'rwk' || key === 'windDirection') continue
|
||||
normalized[key] = String(e[key] ?? '').trim()
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function logEventsEqual(a: LogEventPayload, b: LogEventPayload): boolean {
|
||||
return LOG_EVENT_FIELDS.every((key) => a[key] === b[key])
|
||||
}
|
||||
|
||||
/** Chronological order: earliest time first (HH:MM). */
|
||||
export function sortLogEventsByTime<T extends LogEventPayload>(events: T[]): T[] {
|
||||
return [...events].sort((a, b) => (a.time || '').localeCompare(b.time || ''))
|
||||
}
|
||||
|
||||
export interface LogEntryPayloadInput {
|
||||
date: string
|
||||
dayOfTravel: string
|
||||
@@ -27,6 +126,7 @@ export interface LogEntryPayloadInput {
|
||||
trackDistanceNm?: number
|
||||
trackSpeedMaxKn?: number
|
||||
trackSpeedAvgKn?: number
|
||||
motorHours?: number
|
||||
events: LogEventPayload[]
|
||||
}
|
||||
|
||||
@@ -38,12 +138,15 @@ export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string
|
||||
destination: input.destination.trim(),
|
||||
freshwater: { ...input.freshwater },
|
||||
fuel: { ...input.fuel },
|
||||
events: input.events.map((e) => ({ ...e }))
|
||||
events: sortLogEventsByTime(input.events.map((e) => normalizeLogEvent(e)))
|
||||
}
|
||||
|
||||
if (input.trackDistanceNm !== undefined) payload.trackDistanceNm = input.trackDistanceNm
|
||||
if (input.trackSpeedMaxKn !== undefined) payload.trackSpeedMaxKn = input.trackSpeedMaxKn
|
||||
if (input.trackSpeedAvgKn !== undefined) payload.trackSpeedAvgKn = input.trackSpeedAvgKn
|
||||
if (input.motorHours !== undefined && input.motorHours > 0) {
|
||||
payload.motorHours = Number(input.motorHours.toFixed(2))
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
@@ -41,6 +41,13 @@ export function getClosingTankLevel(tank?: Partial<TankLevels> | null): number {
|
||||
export interface LogEntryTankSource {
|
||||
freshwater?: Partial<TankLevels>
|
||||
fuel?: Partial<TankLevels>
|
||||
destination?: string
|
||||
}
|
||||
|
||||
export interface CarryOverFromPreviousDay {
|
||||
freshwater: TankLevels
|
||||
fuel: TankLevels
|
||||
departure: string
|
||||
}
|
||||
|
||||
export function emptyTankLevels(morning = 0): TankLevels {
|
||||
@@ -62,3 +69,14 @@ export function carryOverTankLevelsFromPreviousDay(previousEntry?: LogEntryTankS
|
||||
fuel: emptyTankLevels(getClosingTankLevel(previousEntry.fuel))
|
||||
}
|
||||
}
|
||||
|
||||
export function carryOverFromPreviousDay(previousEntry?: LogEntryTankSource | null): CarryOverFromPreviousDay {
|
||||
const { freshwater, fuel } = carryOverTankLevelsFromPreviousDay(previousEntry)
|
||||
const departure = previousEntry?.destination?.trim() || ''
|
||||
|
||||
return { freshwater, fuel, departure }
|
||||
}
|
||||
|
||||
export function hasCarryOverFromPreviousDay(carryOver: CarryOverFromPreviousDay): boolean {
|
||||
return carryOver.freshwater.morning > 0 || carryOver.fuel.morning > 0 || carryOver.departure.length > 0
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { LogEventPayload } from './logEntryPayload.js'
|
||||
|
||||
export type PropulsionMode = 'sail' | 'motor'
|
||||
|
||||
const MOTOR_LABELS = ['Maschinenfahrt', 'Engine Propulsion']
|
||||
|
||||
export function isMotorPropulsion(sailsOrMotor: string): boolean {
|
||||
const normalized = sailsOrMotor.trim().toLowerCase()
|
||||
if (!normalized) return false
|
||||
return MOTOR_LABELS.some((label) => normalized.includes(label.toLowerCase()))
|
||||
}
|
||||
|
||||
export function classifyEventPropulsion(event: Pick<LogEventPayload, 'sailsOrMotor'>): PropulsionMode {
|
||||
return isMotorPropulsion(event.sailsOrMotor) ? 'motor' : 'sail'
|
||||
}
|
||||
|
||||
export interface PropulsionDistanceSplit {
|
||||
sailDistanceNm: number
|
||||
motorDistanceNm: number
|
||||
unknownPropulsionNm: number
|
||||
}
|
||||
|
||||
export function splitDistanceByPropulsion(
|
||||
distanceNm: number,
|
||||
events: Pick<LogEventPayload, 'sailsOrMotor'>[]
|
||||
): PropulsionDistanceSplit {
|
||||
if (distanceNm <= 0) {
|
||||
return { sailDistanceNm: 0, motorDistanceNm: 0, unknownPropulsionNm: 0 }
|
||||
}
|
||||
|
||||
const classified = events.filter((e) => e.sailsOrMotor.trim())
|
||||
if (classified.length === 0) {
|
||||
return { sailDistanceNm: 0, motorDistanceNm: 0, unknownPropulsionNm: distanceNm }
|
||||
}
|
||||
|
||||
let motorCount = 0
|
||||
let sailCount = 0
|
||||
for (const event of classified) {
|
||||
if (isMotorPropulsion(event.sailsOrMotor)) {
|
||||
motorCount++
|
||||
} else {
|
||||
sailCount++
|
||||
}
|
||||
}
|
||||
|
||||
const total = motorCount + sailCount
|
||||
const motorDistanceNm = Number(((distanceNm * motorCount) / total).toFixed(2))
|
||||
const sailDistanceNm = Number((distanceNm - motorDistanceNm).toFixed(2))
|
||||
|
||||
return { sailDistanceNm, motorDistanceNm, unknownPropulsionNm: 0 }
|
||||
}
|
||||
|
||||
export function parseEventDistanceNm(distance: string): number {
|
||||
const match = distance.replace(',', '.').match(/(\d+(?:\.\d+)?)/)
|
||||
if (!match) return 0
|
||||
const value = Number(match[1])
|
||||
return Number.isFinite(value) && value > 0 ? value : 0
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { i18n as I18nInstance } from 'i18next'
|
||||
|
||||
const SITE_ORIGIN = 'https://kapteins-daagbok.eu'
|
||||
|
||||
export type SeoLang = 'de' | 'en'
|
||||
|
||||
let i18nRef: I18nInstance | null = null
|
||||
|
||||
export function normalizeSeoLang(lng: string): SeoLang {
|
||||
return lng.startsWith('de') ? 'de' : 'en'
|
||||
}
|
||||
|
||||
function setMeta(attr: 'name' | 'property', key: string, content: string) {
|
||||
let el = document.querySelector(`meta[${attr}="${key}"]`)
|
||||
if (!el) {
|
||||
el = document.createElement('meta')
|
||||
el.setAttribute(attr, key)
|
||||
document.head.appendChild(el)
|
||||
}
|
||||
el.setAttribute('content', content)
|
||||
}
|
||||
|
||||
function syncLanguageUrl(lang: SeoLang) {
|
||||
const url = new URL(window.location.href)
|
||||
const currentLng = url.searchParams.get('lng')
|
||||
if (currentLng && normalizeSeoLang(currentLng) === lang) return
|
||||
|
||||
url.searchParams.set('lng', lang)
|
||||
const next = `${url.pathname}${url.search}${url.hash}`
|
||||
window.history.replaceState({}, '', next)
|
||||
}
|
||||
|
||||
export function updatePageSeo(lng?: string) {
|
||||
if (!i18nRef?.isInitialized) return
|
||||
|
||||
const lang = normalizeSeoLang(lng ?? i18nRef.language)
|
||||
document.documentElement.lang = lang
|
||||
|
||||
const title = i18nRef.t('seo.title')
|
||||
document.title = title
|
||||
|
||||
const description = i18nRef.t('seo.description')
|
||||
const keywords = i18nRef.t('seo.keywords')
|
||||
const imageAlt = i18nRef.t('seo.ogImageAlt')
|
||||
|
||||
setMeta('name', 'description', description)
|
||||
setMeta('name', 'keywords', keywords)
|
||||
setMeta('property', 'og:title', title)
|
||||
setMeta('property', 'og:description', description)
|
||||
setMeta('property', 'og:locale', lang === 'de' ? 'de_DE' : 'en_US')
|
||||
setMeta('property', 'og:locale:alternate', lang === 'de' ? 'en_US' : 'de_DE')
|
||||
setMeta('name', 'twitter:title', title)
|
||||
setMeta('name', 'twitter:description', description)
|
||||
setMeta('property', 'og:image:alt', imageAlt)
|
||||
setMeta('name', 'twitter:image:alt', imageAlt)
|
||||
|
||||
syncLanguageUrl(lang)
|
||||
}
|
||||
|
||||
export function initSeo(i18n: I18nInstance) {
|
||||
i18nRef = i18n
|
||||
i18n.on('initialized', () => updatePageSeo())
|
||||
i18n.on('languageChanged', (lng) => updatePageSeo(lng))
|
||||
if (i18n.isInitialized) {
|
||||
updatePageSeo()
|
||||
}
|
||||
}
|
||||
|
||||
export function hreflangUrl(lang: SeoLang): string {
|
||||
return `${SITE_ORIGIN}/?lng=${lang}`
|
||||
}
|
||||
|
||||
export const seoSiteOrigin = SITE_ORIGIN
|
||||
@@ -1,4 +1,12 @@
|
||||
import type { PasskeySignature, SignatureValue } from '../types/signatures.js'
|
||||
import { hashEntryForSigning } from './entryCanonicalHash.js'
|
||||
import type { ClassicSignature, PasskeySignature, SignatureValue } from '../types/signatures.js'
|
||||
|
||||
export type SkipperSignStatus = 'none' | 'valid' | 'invalid'
|
||||
|
||||
export interface SignatureAttribution {
|
||||
username: string
|
||||
signedAt: string
|
||||
}
|
||||
|
||||
export function isSignatureImage(value: string | undefined | null): boolean {
|
||||
return typeof value === 'string' && value.startsWith('data:image/')
|
||||
@@ -13,9 +21,52 @@ export function isPasskeySignature(value: unknown): value is PasskeySignature {
|
||||
)
|
||||
}
|
||||
|
||||
export function isClassicSignature(value: unknown): value is ClassicSignature {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
(value as ClassicSignature).kind === 'classic' &&
|
||||
(value as ClassicSignature).version === 1
|
||||
)
|
||||
}
|
||||
|
||||
export function getSignaturePayload(value: SignatureValue | '' | undefined | null): string {
|
||||
if (!value) return ''
|
||||
if (isClassicSignature(value)) return value.payload
|
||||
if (isPasskeySignature(value)) return ''
|
||||
return value
|
||||
}
|
||||
|
||||
export function getSignatureAttribution(value: SignatureValue | '' | undefined | null): SignatureAttribution | null {
|
||||
if (!value || typeof value === 'string') return null
|
||||
if (isPasskeySignature(value) || isClassicSignature(value)) {
|
||||
return { username: value.username, signedAt: value.signedAt }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function createClassicSignature(input: {
|
||||
role: 'skipper' | 'crew'
|
||||
userId: string
|
||||
username: string
|
||||
signedAt: string
|
||||
payload: string
|
||||
}): ClassicSignature {
|
||||
return {
|
||||
kind: 'classic',
|
||||
version: 1,
|
||||
role: input.role,
|
||||
userId: input.userId,
|
||||
username: input.username,
|
||||
signedAt: input.signedAt,
|
||||
payload: input.payload
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeSignature(value: unknown): SignatureValue | undefined {
|
||||
if (value === null || value === undefined || value === '') return undefined
|
||||
if (isPasskeySignature(value)) return value
|
||||
if (isClassicSignature(value)) return value
|
||||
if (typeof value === 'string') return value
|
||||
return undefined
|
||||
}
|
||||
@@ -31,9 +82,20 @@ export function isSignatureValidForEntry(sig: PasskeySignature, entryHash: strin
|
||||
return sig.entryHash === entryHash
|
||||
}
|
||||
|
||||
export async function getSkipperSignStatus(
|
||||
entry: Record<string, unknown>
|
||||
): Promise<SkipperSignStatus> {
|
||||
const signSkipper = normalizeSignature(entry.signSkipper)
|
||||
if (!signSkipper) return 'none'
|
||||
if (!isPasskeySignature(signSkipper)) return 'valid'
|
||||
const hash = await hashEntryForSigning(entry)
|
||||
return isSignatureValidForEntry(signSkipper, hash) ? 'valid' : 'invalid'
|
||||
}
|
||||
|
||||
export interface SignatureExportLabels {
|
||||
imagePlaceholder: string
|
||||
passkeyLabel: (username: string, signedAt: string) => string
|
||||
attributionLabel: (username: string, signedAt: string) => string
|
||||
}
|
||||
|
||||
export function formatSignatureForExport(
|
||||
@@ -44,14 +106,27 @@ export function formatSignatureForExport(
|
||||
if (isPasskeySignature(value)) {
|
||||
return labels.passkeyLabel(value.username, value.signedAt)
|
||||
}
|
||||
if (isClassicSignature(value)) {
|
||||
return labels.attributionLabel(value.username, value.signedAt)
|
||||
}
|
||||
if (isSignatureImage(value)) return labels.imagePlaceholder
|
||||
return value
|
||||
}
|
||||
|
||||
export function serializeSignature(value: SignatureValue | '' | undefined): SignatureValue | undefined {
|
||||
if (!value) return undefined
|
||||
if (isPasskeySignature(value)) return value
|
||||
if (isSignatureImage(value)) return value
|
||||
const trimmed = value.trim()
|
||||
if (isPasskeySignature(value) || isClassicSignature(value)) return value
|
||||
const payload = typeof value === 'string' ? value : getSignaturePayload(value)
|
||||
if (isSignatureImage(payload)) return payload
|
||||
const trimmed = payload.trim()
|
||||
return trimmed || undefined
|
||||
}
|
||||
|
||||
/** Normalize then serialize — canonical form for persistence and dirty-check fingerprints. */
|
||||
export function normalizedSerializedSignature(value: unknown): SignatureValue | undefined {
|
||||
return serializeSignature(normalizeSignature(value) || '')
|
||||
}
|
||||
|
||||
export function fingerprintSignature(value: unknown): SignatureValue | '' {
|
||||
return normalizedSerializedSignature(value) ?? ''
|
||||
}
|
||||
|
||||
Vendored
+23
-1
@@ -1,4 +1,26 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/react" />
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
|
||||
declare const __APP_VERSION__: string
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_VAPID_PUBLIC_KEY?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
declare module '*?raw' {
|
||||
const content: string
|
||||
export default content
|
||||
}
|
||||
|
||||
declare global {
|
||||
const __APP_VERSION__: string
|
||||
|
||||
interface Window {
|
||||
plausible?: (event: string, options?: { props?: Record<string, string | number | boolean> }) => void
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
|
||||
@@ -21,5 +21,6 @@
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
|
||||
+15
-3
@@ -1,3 +1,4 @@
|
||||
/// <reference types="vitest/config" />
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
@@ -20,6 +21,10 @@ function readAppVersion(): string {
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
include: ['src/**/*.test.ts']
|
||||
},
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(readAppVersion())
|
||||
},
|
||||
@@ -38,16 +43,23 @@ export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
strategies: 'injectManifest',
|
||||
srcDir: 'src',
|
||||
filename: 'sw.ts',
|
||||
registerType: 'prompt',
|
||||
devOptions: {
|
||||
enabled: false
|
||||
},
|
||||
includeAssets: ['favicon.ico', 'logo.png'],
|
||||
workbox: {
|
||||
cleanupOutdatedCaches: true,
|
||||
injectManifest: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2,webmanifest}']
|
||||
},
|
||||
manifest: {
|
||||
name: 'Kapteins Daagbok',
|
||||
short_name: 'Daagbok',
|
||||
description: 'Digital maritime ship logbook with E2E encryption and Passkeys',
|
||||
lang: 'de',
|
||||
description:
|
||||
'Digitales Yacht-Logbuch — E2E-verschlüsselt, offline-fähig.',
|
||||
theme_color: '#1e293b',
|
||||
background_color: '#0f172a',
|
||||
display: 'standalone',
|
||||
|
||||
@@ -26,6 +26,14 @@ services:
|
||||
DATABASE_URL: "postgresql://postgres:postgres@db:5432/daagbox?schema=public"
|
||||
RP_ID: ${RP_ID:-localhost}
|
||||
ORIGIN: ${ORIGIN:-http://localhost}
|
||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
|
||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
|
||||
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:support@kapteins-daagbok.eu}
|
||||
OpenWeatherMapAPIKey: ${OpenWeatherMapAPIKey:-}
|
||||
SESSION_SECRET: ${SESSION_SECRET:-}
|
||||
NTFY_SERVER: ${NTFY_SERVER:-https://ntfy.sh}
|
||||
NTFY_TOPIC: ${NTFY_TOPIC:-}
|
||||
NTFY_TOKEN: ${NTFY_TOKEN:-}
|
||||
command: sh -c "npx prisma db push && node dist/index.js"
|
||||
depends_on:
|
||||
db:
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 125 KiB |
@@ -0,0 +1,381 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Kapteins Daagbok — Beta-Flyer</title>
|
||||
<style>
|
||||
@page {
|
||||
size: A4 portrait;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 210mm;
|
||||
height: 297mm;
|
||||
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
||||
color: #e2e8f0;
|
||||
background: #0f172a;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
|
||||
.page {
|
||||
width: 210mm;
|
||||
height: 297mm;
|
||||
max-height: 297mm;
|
||||
padding: 12mm 15mm 10mm;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5mm;
|
||||
background:
|
||||
radial-gradient(ellipse 120% 80% at 100% 0%, rgba(56, 189, 248, 0.12) 0%, transparent 55%),
|
||||
radial-gradient(ellipse 90% 60% at 0% 100%, rgba(134, 59, 255, 0.14) 0%, transparent 50%),
|
||||
linear-gradient(165deg, #0f172a 0%, #1e293b 45%, #0f172a 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 8mm;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border-radius: 4mm;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5mm;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 16mm;
|
||||
height: 16mm;
|
||||
flex-shrink: 0;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.title-block h1 {
|
||||
font-size: 23pt;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: #f8fafc;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.title-block p {
|
||||
font-size: 12pt;
|
||||
color: #94a3b8;
|
||||
margin-top: 1.5mm;
|
||||
}
|
||||
|
||||
.badge {
|
||||
margin-left: auto;
|
||||
align-self: flex-start;
|
||||
background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
|
||||
color: #1e293b;
|
||||
font-size: 11pt;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.12em;
|
||||
padding: 2.5mm 4.5mm;
|
||||
border-radius: 2mm;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.intro {
|
||||
font-size: 12pt;
|
||||
line-height: 1.5;
|
||||
color: #cbd5e1;
|
||||
flex-shrink: 0;
|
||||
max-width: 95%;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.intro strong {
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.screenshots {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 3mm;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.screenshot-card {
|
||||
border-radius: 2.5mm;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
background: rgba(15, 23, 42, 0.55);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.screenshot-card img {
|
||||
width: 100%;
|
||||
height: 50mm;
|
||||
object-fit: contain;
|
||||
object-position: top center;
|
||||
display: block;
|
||||
background: #0b1220;
|
||||
}
|
||||
|
||||
.screenshot-caption {
|
||||
font-size: 9pt;
|
||||
color: #94a3b8;
|
||||
text-align: center;
|
||||
padding: 1.5mm 2mm;
|
||||
line-height: 1.3;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.features {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.8mm 6mm;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.feature {
|
||||
display: flex;
|
||||
gap: 2.5mm;
|
||||
align-items: flex-start;
|
||||
font-size: 10.5pt;
|
||||
line-height: 1.28;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
color: #38bdf8;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
width: 4mm;
|
||||
}
|
||||
|
||||
.lang-list {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 1.5mm;
|
||||
}
|
||||
|
||||
.lang-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 1.2mm;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.feature-flag {
|
||||
display: inline-block;
|
||||
width: 5mm;
|
||||
height: 3.5mm;
|
||||
border-radius: 0.3mm;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 0 0 0.15mm rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.lang-sep {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.beta-box {
|
||||
background: rgba(30, 41, 59, 0.85);
|
||||
border: 1px solid rgba(251, 191, 36, 0.35);
|
||||
border-left: 3px solid #fbbf24;
|
||||
border-radius: 3mm;
|
||||
padding: 5mm 6mm;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.beta-box h2 {
|
||||
font-size: 12.5pt;
|
||||
color: #fbbf24;
|
||||
margin-bottom: 2mm;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.beta-box p {
|
||||
font-size: 10.5pt;
|
||||
line-height: 1.5;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.cta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7mm;
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
border-radius: 4mm;
|
||||
padding: 5mm 6mm;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.qr {
|
||||
width: 32mm;
|
||||
height: 32mm;
|
||||
background: #fff;
|
||||
padding: 2mm;
|
||||
border-radius: 2mm;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.qr img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cta-text h3 {
|
||||
font-size: 14.5pt;
|
||||
color: #38bdf8;
|
||||
font-weight: 700;
|
||||
margin-bottom: 2mm;
|
||||
}
|
||||
|
||||
.cta-text p {
|
||||
font-size: 11pt;
|
||||
color: #94a3b8;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2mm;
|
||||
margin-top: 3mm;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 9.5pt;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
border: 1px solid rgba(100, 116, 139, 0.4);
|
||||
border-radius: 1.5mm;
|
||||
padding: 1mm 2.5mm;
|
||||
}
|
||||
|
||||
footer {
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.15);
|
||||
padding-top: 3mm;
|
||||
margin-top: auto;
|
||||
flex-shrink: 0;
|
||||
font-size: 9.5pt;
|
||||
line-height: 1.5;
|
||||
color: #64748b;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
footer strong {
|
||||
color: #94a3b8;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<article class="page">
|
||||
<header>
|
||||
<img class="logo" src="../../client/public/logo.png" alt="Kapteins Daagbok" />
|
||||
<div class="title-block">
|
||||
<h1>Kapteins Daagbok</h1>
|
||||
<p>Digitales Yacht-Logbuch — kostenlos & werbefrei</p>
|
||||
</div>
|
||||
<span class="badge">Beta</span>
|
||||
</header>
|
||||
|
||||
<p class="intro">
|
||||
Führe dein Bordlogbuch digital: Reisetage, GPS-Tracks, Crew- und Schiffsdaten —
|
||||
<strong>Ende-zu-Ende-verschlüsselt</strong>, als App installierbar und
|
||||
<strong>auch offline</strong> auf See nutzbar.
|
||||
</p>
|
||||
|
||||
<section class="features" aria-label="Funktionen">
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Reisetage im nautischen Logbuch-Format (Hafen, Wetter, Besegelung, Crew, Tankstände)</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Offline-fähige PWA — läuft auf jedem Smartphone & Tablet</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Einfache passwortlose Passkey-Anmeldung</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Ende-zu-Ende Verschlüsselung</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>GPS-Track Upload (GPX/KML) mit Kartendarstellung</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Streckenstatistik</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Foto-Anhänge pro Reisetag</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Foto-Anhänge für Skipper und Crew</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Crew einladen — gemeinsam am Logbuch arbeiten</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>PDF- & CSV-Export</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Verschlüsseltes Backup & Wiederherstellung</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Logbuch mit Freunden teilen</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Beliebig viele Schiffe und Logbücher</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span class="lang-list"><span class="lang-item"><svg class="feature-flag" viewBox="0 0 5 3" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect width="5" height="1" fill="#000"/><rect y="1" width="5" height="1" fill="#D00"/><rect y="2" width="5" height="1" fill="#FFCE00"/></svg>Deutsch</span><span class="lang-sep">&</span><span class="lang-item"><svg class="feature-flag" viewBox="0 0 60 30" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><clipPath id="gb-a"><path d="M0 0v30h60V0z"/></clipPath><clipPath id="gb-b"><path d="M30 15h30v15zv15H0z"/></clipPath><g clip-path="url(#gb-a)"><path d="M0 0v30h60V0z" fill="#012169"/><path d="M0 0l60 30m0-30L0 30" stroke="#fff" stroke-width="6"/><path d="M0 0l60 30m0-30L0 30" clip-path="url(#gb-b)" stroke="#C8102E" stroke-width="4"/><path d="M30 0v30M0 15h60" stroke="#fff" stroke-width="10"/><path d="M30 0v30M0 15h60" stroke="#C8102E" stroke-width="6"/></g></svg>Englisch</span></span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>3 Themes, jeweils mit heller und dunkler Variante</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Crafted in Kiel.Sailing.City.</span></div>
|
||||
</section>
|
||||
|
||||
<section class="screenshots" aria-label="App-Screenshots">
|
||||
<figure class="screenshot-card">
|
||||
<img src="assets/screenshot-login.png" alt="Anmeldung mit Passkey und Demo" />
|
||||
<figcaption class="screenshot-caption">Anmeldung & Passkey</figcaption>
|
||||
</figure>
|
||||
<figure class="screenshot-card">
|
||||
<img src="assets/screenshot-logbook.png" alt="Logbuch-Journal mit Reisetagen" />
|
||||
<figcaption class="screenshot-caption">Logbuch-Journal</figcaption>
|
||||
</figure>
|
||||
<figure class="screenshot-card">
|
||||
<img src="assets/screenshot-vessel.png" alt="Schiffs-Stammdaten mit Yachtfoto" />
|
||||
<figcaption class="screenshot-caption">Schiffsdaten</figcaption>
|
||||
</figure>
|
||||
</section>
|
||||
|
||||
<section class="beta-box">
|
||||
<h2>Beta-Phase — Dein Feedback zählt</h2>
|
||||
<p>
|
||||
Kapteins Daagbok ist ein <strong>privates Hobbyprojekt ohne Gewinnabsicht</strong>.
|
||||
Als Beta-Tester hilfst du, die App für Skipper und Crew im Alltag zu verbessern —
|
||||
Rückmeldungen sind ausdrücklich willkommen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="cta">
|
||||
<div class="qr">
|
||||
<img src="assets/qr-kapteins-daagbok.eu.png" alt="QR-Code: kapteins-daagbok.eu" />
|
||||
</div>
|
||||
<div class="cta-text">
|
||||
<h3>kapteins-daagbok.eu</h3>
|
||||
<p>Im Browser öffnen oder als App zum Home-Bildschirm hinzufügen. Registrierung mit Passkey — kein App-Store nötig.</p>
|
||||
<div class="tags">
|
||||
<span class="tag">Kostenlos</span>
|
||||
<span class="tag">Werbefrei</span>
|
||||
<span class="tag">E2E-verschlüsselt</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<strong>Impressum</strong><br />
|
||||
KnorrLabs · Markus F.J. Busche · Knorrstr. 16 · 24106 Kiel · elpatron+kd@mailbox.org
|
||||
</footer>
|
||||
</article>
|
||||
</body>
|
||||
</html>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user