Compare commits
91 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| f01c5dc86f | |||
| 1f089fdaa7 | |||
| b2a28f5782 | |||
| 4d2e309967 | |||
| 2f6c668ca4 | |||
| 42736fedf3 | |||
| ac84fef832 | |||
| 404eb79add | |||
| 14b52c684d |
@@ -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 |
|
||||
+13
-1
@@ -4,4 +4,16 @@ 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
|
||||
ORIGIN=http://localhost
|
||||
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
# 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) via `@simplewebauthn` |
|
||||
| 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.
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
Für lokale Passkeys: `RP_ID=localhost`, `ORIGIN=http://localhost:5173` (bzw. die tatsächliche Frontend-URL).
|
||||
|
||||
Im `server/`-Verzeichnis eine `.env` mit `DATABASE_URL` anlegen — oder den Key in der **Projekt-`.env`** (`OpenWeatherMapAPIKey=...`); das Backend lädt beide Dateien.
|
||||
|
||||
```
|
||||
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
|
||||
# Optional — Web Push (npx web-push generate-vapid-keys)
|
||||
VAPID_PUBLIC_KEY=
|
||||
VAPID_PRIVATE_KEY=
|
||||
VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu
|
||||
```
|
||||
|
||||
### 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` und `ORIGIN` für Passkeys. Für Push die VAPID-Variablen an den **Backend**-Container durchreichen (z. B. in `docker-compose.yml` unter `backend.environment` ergänzen).
|
||||
|
||||
## 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` 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)
|
||||
+23
-2
@@ -1,16 +1,37 @@
|
||||
<!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" />
|
||||
<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/" />
|
||||
<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>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -3,6 +3,17 @@ server {
|
||||
server_name localhost;
|
||||
client_max_body_size 50M;
|
||||
|
||||
# Service worker and app shell must revalidate so PWA updates are detected
|
||||
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest)$ {
|
||||
root /usr/share/nginx/html;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
}
|
||||
|
||||
location = /index.html {
|
||||
root /usr/share/nginx/html;
|
||||
add_header Cache-Control "no-cache, must-revalidate";
|
||||
}
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
|
||||
Generated
+376
-1
@@ -32,10 +32,15 @@
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.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"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-linux-x64-gnu": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@apideck/better-ajv-errors": {
|
||||
@@ -2096,7 +2101,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3107,6 +3111,32 @@
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/array-buffer-byte-length": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz",
|
||||
@@ -3377,6 +3407,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001793",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz",
|
||||
@@ -3418,6 +3458,38 @@
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "2.20.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||
@@ -3582,6 +3654,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
@@ -3661,6 +3743,13 @@
|
||||
"react": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.7",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz",
|
||||
@@ -3709,6 +3798,13 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/es-abstract": {
|
||||
"version": "1.24.2",
|
||||
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
|
||||
@@ -4383,6 +4479,16 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
@@ -4905,6 +5011,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-generator-function": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
|
||||
@@ -5859,6 +5975,16 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/package-json-from-dist": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||
@@ -5953,6 +6079,63 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.51.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.51.0.tgz",
|
||||
"integrity": "sha512-442pTfGM0xxfCYxuBa/Pu6B2OqxqqaYq39JS8QDMGThUvIOCd6s0ANDog3uwA0cHavVlnTQzGCN7Id2YekDSXA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.51.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.51.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.51.0.tgz",
|
||||
"integrity": "sha512-x47yPE3Zwhlil7wlNU/iktF7t2r/URR3VLbH6EknJd/04Qc/PSJ0EY3CMXipmglLG+zyRxW6HNo2EGbKLHPWMg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
@@ -6025,6 +6208,24 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/raf": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
|
||||
@@ -6192,6 +6393,16 @@
|
||||
"regjsparser": "bin/parser"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
@@ -6202,6 +6413,13 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.12",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||
@@ -6401,6 +6619,13 @@
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/set-function-length": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||
@@ -6641,6 +6866,21 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/string.prototype.matchall": {
|
||||
"version": "4.0.12",
|
||||
"resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz",
|
||||
@@ -6743,6 +6983,19 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-comments": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz",
|
||||
@@ -7408,6 +7661,13 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/which-typed-array": {
|
||||
"version": "1.1.21",
|
||||
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.21.tgz",
|
||||
@@ -7660,6 +7920,28 @@
|
||||
"workbox-core": "7.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
@@ -7667,6 +7949,99 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
||||
+8
-1
@@ -7,7 +7,9 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"generate:flyer": "node ../scripts/generate-beta-flyer.mjs",
|
||||
"generate:flyer:setup": "playwright install chromium"
|
||||
},
|
||||
"dependencies": {
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
@@ -34,9 +36,14 @@
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.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"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-linux-x64-gnu": "^1.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
+1223
-333
File diff suppressed because it is too large
Load Diff
+289
-50
@@ -1,33 +1,61 @@
|
||||
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 LogbookDashboard from './components/LogbookDashboard.tsx'
|
||||
import VesselForm from './components/VesselForm.tsx'
|
||||
import CrewForm from './components/CrewForm.tsx'
|
||||
import DeviationForm from './components/DeviationForm.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 AppTourOverlay from './components/AppTourOverlay.tsx'
|
||||
import { AppTourProvider, useAppTour, type AppTab } from './context/AppTourContext.tsx'
|
||||
import { getActiveMasterKey, logoutUser } from './services/auth.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from './services/analytics.js'
|
||||
import {
|
||||
applyAppearanceToDocument,
|
||||
resolveAppTheme,
|
||||
resolveColorScheme,
|
||||
subscribeToSystemColorScheme
|
||||
} 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 { 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, Compass, 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 } 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 { registerNavigation, requestStartAfterLogin } = 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' | 'deviation' | 'logs' | 'settings'>('logs')
|
||||
const [activeTab, setActiveTab] = useState<AppTab>('logs')
|
||||
const [tourSelectedEntryId, setTourSelectedEntryId] = useState<string | null>(null)
|
||||
const [demoHighlightEntryId, setDemoHighlightEntryId] = useState<string | null>(null)
|
||||
const [online, setOnline] = useState(navigator.onLine)
|
||||
const [isSyncing, setIsSyncing] = useState(false)
|
||||
const [appliedTheme, setAppliedTheme] = useState<'ocean' | 'material' | 'cupertino'>('ocean')
|
||||
const [isAcceptingInvite, setIsAcceptingInvite] = useState(false)
|
||||
|
||||
// Viewer mode for read-only shared links
|
||||
@@ -35,32 +63,52 @@ function App() {
|
||||
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 updateAppliedTheme = () => {
|
||||
const configTheme = localStorage.getItem('active_theme') || 'auto'
|
||||
if (configTheme === 'auto') {
|
||||
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera
|
||||
if (/iPad|iPhone|iPod|Macintosh/.test(userAgent)) {
|
||||
setAppliedTheme('cupertino')
|
||||
} else if (/Android|Linux/.test(userAgent)) {
|
||||
setAppliedTheme('material')
|
||||
} else {
|
||||
setAppliedTheme('ocean')
|
||||
}
|
||||
} else {
|
||||
setAppliedTheme(configTheme as 'ocean' | 'material' | 'cupertino')
|
||||
}
|
||||
}
|
||||
const activeLogbookRecord = useLiveQuery(
|
||||
() => (activeLogbookId ? db.logbooks.get(activeLogbookId) : undefined),
|
||||
[activeLogbookId]
|
||||
)
|
||||
|
||||
const [activeAccessRole, setActiveAccessRole] = useState<LogbookAccessRole>('OWNER')
|
||||
|
||||
useEffect(() => {
|
||||
updateAppliedTheme()
|
||||
window.addEventListener('theme-changed', updateAppliedTheme)
|
||||
if (!activeLogbookId) {
|
||||
setActiveAccessRole('OWNER')
|
||||
return
|
||||
}
|
||||
|
||||
if (activeLogbookRecord?.isShared !== 1) {
|
||||
setActiveAccessRole('OWNER')
|
||||
return
|
||||
}
|
||||
|
||||
const cachedRole = activeLogbookRecord.collaborationRole
|
||||
if (cachedRole) {
|
||||
setActiveAccessRole(cachedRole)
|
||||
}
|
||||
|
||||
getLogbookAccess(activeLogbookId).then((access) => {
|
||||
if (access) setActiveAccessRole(access.role)
|
||||
})
|
||||
}, [activeLogbookId, activeLogbookRecord])
|
||||
|
||||
useEffect(() => {
|
||||
const syncAppearance = () => {
|
||||
applyAppearanceToDocument(resolveAppTheme(), resolveColorScheme())
|
||||
}
|
||||
syncAppearance()
|
||||
window.addEventListener('appearance-changed', syncAppearance)
|
||||
const unsubscribeSystem = subscribeToSystemColorScheme(syncAppearance)
|
||||
return () => {
|
||||
window.removeEventListener('theme-changed', updateAppliedTheme)
|
||||
window.removeEventListener('appearance-changed', syncAppearance)
|
||||
unsubscribeSystem()
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -95,19 +143,47 @@ 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
|
||||
}
|
||||
|
||||
setIsDemoMode(false)
|
||||
|
||||
if (path === '/share' && params.has('token') && hashParams.has('key')) {
|
||||
setShareToken(params.get('token') || '')
|
||||
setShareKey(hashParams.get('key') || '')
|
||||
setIsViewerMode(true)
|
||||
setIsAcceptingInvite(false)
|
||||
return
|
||||
}
|
||||
|
||||
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 savedUser = localStorage.getItem('active_username')
|
||||
@@ -123,14 +199,111 @@ function App() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleAuthenticated = () => {
|
||||
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
|
||||
})
|
||||
}, [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 = () => {
|
||||
@@ -138,27 +311,41 @@ function App() {
|
||||
setIsAuthenticated(false)
|
||||
setActiveLogbookId(null)
|
||||
setActiveLogbookTitle(null)
|
||||
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 = () => {
|
||||
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 className={`theme-${appliedTheme}`} style={{ display: 'contents' }}>
|
||||
<div style={{ display: 'contents' }}>
|
||||
<ReadOnlyViewer token={shareToken} hexKey={shareKey} />
|
||||
</div>
|
||||
)
|
||||
@@ -166,12 +353,13 @@ function App() {
|
||||
|
||||
if (isAcceptingInvite) {
|
||||
return (
|
||||
<div className={`theme-${appliedTheme} auth-screen`}>
|
||||
<div className="auth-screen">
|
||||
<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)
|
||||
}}
|
||||
@@ -186,8 +374,8 @@ function App() {
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className={`theme-${appliedTheme} auth-screen`}>
|
||||
<AuthOnboarding onAuthenticated={handleAuthenticated} />
|
||||
<div className="auth-screen">
|
||||
<AuthOnboarding onAuthenticated={handleAuthenticated} onOpenDemo={openDemo} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -196,10 +384,10 @@ function App() {
|
||||
|
||||
if (!activeLogbookId) {
|
||||
return (
|
||||
<div className={`theme-${appliedTheme}`} style={{ display: 'contents' }}>
|
||||
<div style={{ display: 'contents' }}>
|
||||
{pwaInstallBanner}
|
||||
<LogbookDashboard
|
||||
onSelectLogbook={handleSelectLogbook}
|
||||
onSelectLogbook={selectLogbook}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
</div>
|
||||
@@ -207,7 +395,7 @@ function App() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`theme-${appliedTheme}`} style={{ display: 'contents' }}>
|
||||
<div style={{ display: 'contents' }}>
|
||||
{pwaInstallBanner}
|
||||
{isSyncing && <div className="sync-progress-bar" />}
|
||||
<div className="app-layout">
|
||||
@@ -219,8 +407,17 @@ function App() {
|
||||
{t('nav.dashboard')}
|
||||
</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>
|
||||
{activeAccessRole !== 'OWNER' && (
|
||||
<LogbookRoleBadge role={activeAccessRole} />
|
||||
)}
|
||||
</div>
|
||||
<p className="app-subtitle">
|
||||
{activeAccessRole !== 'OWNER'
|
||||
? t('dashboard.section_shared_hint')
|
||||
: `${t('app.name')} / ${activeLogbookId?.substring(0, 8)}...`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -237,6 +434,17 @@ 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}
|
||||
/>
|
||||
|
||||
<button className="btn-icon logout" onClick={handleLogout} title={t('dashboard.logout')}>
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
@@ -250,6 +458,7 @@ function App() {
|
||||
<button
|
||||
className={`sidebar-btn ${activeTab === 'logs' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('logs')}
|
||||
data-tour="nav-logs"
|
||||
>
|
||||
<FileText size={18} />
|
||||
{t('nav.logs')}
|
||||
@@ -258,6 +467,7 @@ function App() {
|
||||
<button
|
||||
className={`sidebar-btn ${activeTab === 'vessel' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('vessel')}
|
||||
data-tour="nav-vessel"
|
||||
>
|
||||
<Ship size={18} />
|
||||
{t('nav.vessel')}
|
||||
@@ -266,11 +476,13 @@ function App() {
|
||||
<button
|
||||
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('crew')}
|
||||
data-tour="nav-crew"
|
||||
>
|
||||
<Users size={18} />
|
||||
{t('nav.crew')}
|
||||
</button>
|
||||
|
||||
{/* Compass Deviation Table — für Freizeit-Skipper vorerst ausgeblendet
|
||||
<button
|
||||
className={`sidebar-btn ${activeTab === 'deviation' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('deviation')}
|
||||
@@ -278,6 +490,15 @@ function App() {
|
||||
<Compass size={18} />
|
||||
{t('nav.deviation')}
|
||||
</button>
|
||||
*/}
|
||||
|
||||
<button
|
||||
className={`sidebar-btn ${activeTab === 'stats' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('stats')}
|
||||
>
|
||||
<BarChart2 size={18} />
|
||||
{t('nav.stats')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`sidebar-btn ${activeTab === 'settings' ? 'active' : ''}`}
|
||||
@@ -291,7 +512,12 @@ function App() {
|
||||
{/* Tab Content Panels (Placeholder until Phase 3) */}
|
||||
<main className="app-content">
|
||||
{activeTab === 'logs' && (
|
||||
<LogEntriesList logbookId={activeLogbookId} />
|
||||
<LogEntriesList
|
||||
logbookId={activeLogbookId}
|
||||
controlledSelectedEntryId={tourSelectedEntryId}
|
||||
onSelectedEntryIdChange={setTourSelectedEntryId}
|
||||
highlightEntryId={demoHighlightEntryId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'vessel' && (
|
||||
@@ -302,12 +528,21 @@ function App() {
|
||||
<CrewForm logbookId={activeLogbookId} />
|
||||
)}
|
||||
|
||||
{activeTab === 'stats' && activeLogbookId && activeLogbookTitle && (
|
||||
<StatsDashboard logbookId={activeLogbookId} logbookTitle={activeLogbookTitle} />
|
||||
)}
|
||||
|
||||
{/* Compass Deviation Table — für Freizeit-Skipper vorerst deaktiviert
|
||||
{activeTab === 'deviation' && (
|
||||
<DeviationForm logbookId={activeLogbookId} />
|
||||
)}
|
||||
*/}
|
||||
|
||||
{activeTab === 'settings' && (
|
||||
<SettingsForm logbookId={activeLogbookId} />
|
||||
<SettingsForm
|
||||
logbookId={activeLogbookId}
|
||||
onLogbookRestored={selectLogbook}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
@@ -319,7 +554,11 @@ function App() {
|
||||
export default function AppWrapper() {
|
||||
return (
|
||||
<DialogProvider>
|
||||
<App />
|
||||
<AppTourProvider>
|
||||
<PwaUpdatePrompt />
|
||||
<App />
|
||||
<AppTourOverlay />
|
||||
</AppTourProvider>
|
||||
<AppFooter />
|
||||
</DialogProvider>
|
||||
)
|
||||
|
||||
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
@@ -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,184 @@
|
||||
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
|
||||
}
|
||||
|
||||
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)`
|
||||
}
|
||||
|
||||
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: Math.min(window.innerHeight - 220, spotlight.top + spotlight.height + 12),
|
||||
left: Math.min(window.innerWidth - 340, Math.max(16, spotlight.left)),
|
||||
maxWidth: '420px'
|
||||
}
|
||||
: { top: '20%', left: '50%', transform: 'translateX(-50%)', maxWidth: '420px' }
|
||||
|
||||
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,14 @@ import {
|
||||
forgetUsername
|
||||
} from '../services/auth.js'
|
||||
import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react'
|
||||
import RegistrationDisclaimer from './RegistrationDisclaimer.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 +47,23 @@ 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 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 +73,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 +168,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 +218,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 (
|
||||
@@ -266,7 +291,7 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={onAuthenticated}
|
||||
onClick={finishAuth}
|
||||
disabled={loading}
|
||||
>
|
||||
{t('auth.skip_pin')}
|
||||
@@ -499,6 +524,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">
|
||||
|
||||
@@ -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 { useDialog } from './ModalDialog.tsx'
|
||||
import { Users, User, Plus, Trash2, Edit2, Save, X, Check, Camera } from 'lucide-react'
|
||||
|
||||
@@ -236,6 +237,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 +339,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)
|
||||
@@ -452,6 +455,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')
|
||||
}
|
||||
@@ -659,6 +663,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,148 @@
|
||||
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
|
||||
})
|
||||
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,37 @@
|
||||
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
|
||||
}
|
||||
|
||||
export default function FeedbackHeaderButton({
|
||||
logbookId,
|
||||
logbookTitle
|
||||
}: FeedbackHeaderButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
onClick={() => setOpen(true)}
|
||||
title={t('feedback.button_title')}
|
||||
aria-label={t('feedback.button_title')}
|
||||
>
|
||||
<MessageSquarePlus size={18} />
|
||||
</button>
|
||||
<FeedbackModal
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
logbookId={logbookId}
|
||||
logbookTitle={logbookTitle}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
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
|
||||
}
|
||||
|
||||
type SubmitState = 'idle' | 'submitting' | 'success' | 'error'
|
||||
|
||||
export default function FeedbackModal({
|
||||
open,
|
||||
onClose,
|
||||
logbookId,
|
||||
logbookTitle
|
||||
}: FeedbackModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const [category, setCategory] = useState<FeedbackCategory>('general')
|
||||
const [contactEmail, setContactEmail] = useState('')
|
||||
const [message, setMessage] = useState('')
|
||||
const [submitState, setSubmitState] = useState<SubmitState>('idle')
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null)
|
||||
const closeTimerRef = useRef<number | null>(null)
|
||||
|
||||
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('')
|
||||
setSubmitState('idle')
|
||||
setStatusMessage(null)
|
||||
}
|
||||
}, [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
|
||||
})
|
||||
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')
|
||||
: t('feedback.error_send')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="disclaimer-modal-overlay" onClick={isBusy ? undefined : onClose}>
|
||||
<div className="disclaimer-modal-panel" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="auth-card glass registration-disclaimer registration-disclaimer--modal feedback-modal">
|
||||
<button
|
||||
type="button"
|
||||
className="registration-disclaimer__close feedback-modal__close"
|
||||
onClick={onClose}
|
||||
disabled={isBusy}
|
||||
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__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'}
|
||||
>
|
||||
{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,8 +10,10 @@ 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'
|
||||
|
||||
interface InvitationAcceptanceProps {
|
||||
onAccepted: (logbookId: string, title: string) => void
|
||||
@@ -137,13 +139,17 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
||||
const masterKey = getActiveMasterKey()
|
||||
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).')
|
||||
setIsLoggedIn(false)
|
||||
return
|
||||
}
|
||||
if (!logbookKey || !logbookId) return
|
||||
if (!logbookKey || !logbookId) {
|
||||
autoAcceptStarted.current = false
|
||||
return
|
||||
}
|
||||
|
||||
setAccepting(true)
|
||||
setError(null)
|
||||
@@ -177,6 +183,9 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
||||
throw new Error(serverError.error || (isDe ? 'Beitritt auf dem Server fehlgeschlagen.' : 'Failed to join logbook on the server.'))
|
||||
}
|
||||
|
||||
const acceptResult = await res.json()
|
||||
const collaborationRole = parseCollaborationRole(acceptResult.role, 'invitation accept')
|
||||
|
||||
await saveLogbookKey(logbookId, logbookKey)
|
||||
|
||||
if (rawEncryptedTitle) {
|
||||
@@ -184,11 +193,14 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
||||
id: logbookId,
|
||||
encryptedTitle: rawEncryptedTitle,
|
||||
updatedAt: new Date().toISOString(),
|
||||
isSynced: 1
|
||||
isSynced: 1,
|
||||
isShared: 1,
|
||||
collaborationRole
|
||||
})
|
||||
}
|
||||
|
||||
await syncLogbook(logbookId)
|
||||
trackPlausibleEvent(PlausibleEvents.INVITE_ACCEPTED)
|
||||
onAccepted(logbookId, decryptedTitle)
|
||||
} catch (err: any) {
|
||||
console.error('Accepting invitation failed:', err)
|
||||
@@ -202,7 +214,10 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
||||
useEffect(() => {
|
||||
if (loading || accepting || autoAcceptStarted.current) return
|
||||
if (!isLoggedIn || !logbookId || !logbookKey || !token) return
|
||||
if (!sessionReady()) return
|
||||
if (!sessionReady()) {
|
||||
autoAcceptStarted.current = false
|
||||
return
|
||||
}
|
||||
|
||||
autoAcceptStarted.current = true
|
||||
void handleAccept()
|
||||
@@ -240,10 +255,17 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
||||
e.preventDefault()
|
||||
if (!recoveryInput.trim() || !encryptedPayloads) return
|
||||
|
||||
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.')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setAuthError(null)
|
||||
try {
|
||||
const resolvedUser = username.trim() || encryptedPayloads.username
|
||||
const success = await completeLoginWithRecovery(resolvedUser, recoveryInput.trim(), encryptedPayloads)
|
||||
if (success) {
|
||||
setShowRecoveryFallback(false)
|
||||
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { db } from '../services/db.js'
|
||||
import { getActiveMasterKey } from '../services/auth.js'
|
||||
@@ -6,23 +6,26 @@ import { getLogbookKey } from '../services/logbookKeys.js'
|
||||
import { encryptJson, decryptJson } from '../services/crypto.js'
|
||||
import { syncLogbook } from '../services/sync.js'
|
||||
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
||||
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload } from 'lucide-react'
|
||||
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X } from 'lucide-react'
|
||||
import PhotoCapture from './PhotoCapture.tsx'
|
||||
import SignatureSection from './SignatureSection.tsx'
|
||||
import TrackMap from './TrackMap.tsx'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import {
|
||||
normalizeSignature,
|
||||
serializeSignature,
|
||||
fingerprintSignature,
|
||||
normalizedSerializedSignature,
|
||||
isPasskeySignature,
|
||||
isSignatureValidForEntry,
|
||||
hasAnySignature
|
||||
} from '../utils/signatures.js'
|
||||
import type { SignatureValue } from '../types/signatures.js'
|
||||
import { buildLogEntryPayload } from '../utils/logEntryPayload.js'
|
||||
import { buildLogEntryPayload, type LogEventPayload } from '../utils/logEntryPayload.js'
|
||||
import { hashEntryForSigning } from '../utils/entryCanonicalHash.js'
|
||||
import { signLogEntry } from '../services/entrySigning.js'
|
||||
import { getLogbookAccess } from '../services/logbookAccess.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
|
||||
import {
|
||||
getDecryptedTrack,
|
||||
saveUploadedTrack,
|
||||
@@ -33,6 +36,56 @@ import {
|
||||
} from '../services/trackUpload.js'
|
||||
import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
|
||||
|
||||
function emptyTankLevels() {
|
||||
return { morning: 0, refilled: 0, evening: 0, consumption: 0 }
|
||||
}
|
||||
|
||||
function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string {
|
||||
const fw = (decrypted.freshwater as Record<string, number> | undefined) ?? emptyTankLevels()
|
||||
const fuel = (decrypted.fuel as Record<string, number> | undefined) ?? emptyTankLevels()
|
||||
const trackDistance = decrypted.trackDistanceNm
|
||||
const trackSpeedMax = decrypted.trackSpeedMaxKn
|
||||
const trackSpeedAvg = decrypted.trackSpeedAvgKn
|
||||
|
||||
const payload = buildLogEntryPayload({
|
||||
date: String(decrypted.date || ''),
|
||||
dayOfTravel: String(decrypted.dayOfTravel || ''),
|
||||
departure: String(decrypted.departure || ''),
|
||||
destination: String(decrypted.destination || ''),
|
||||
freshwater: {
|
||||
morning: fw.morning || 0,
|
||||
refilled: fw.refilled || 0,
|
||||
evening: fw.evening || 0,
|
||||
consumption: fw.consumption ?? 0
|
||||
},
|
||||
fuel: {
|
||||
morning: fuel.morning || 0,
|
||||
refilled: fuel.refilled || 0,
|
||||
evening: fuel.evening || 0,
|
||||
consumption: fuel.consumption ?? 0
|
||||
},
|
||||
trackDistanceNm:
|
||||
trackDistance != null && trackDistance !== ''
|
||||
? parseFloat(String(trackDistance))
|
||||
: undefined,
|
||||
trackSpeedMaxKn:
|
||||
trackSpeedMax != null && trackSpeedMax !== ''
|
||||
? parseFloat(String(trackSpeedMax))
|
||||
: undefined,
|
||||
trackSpeedAvgKn:
|
||||
trackSpeedAvg != null && trackSpeedAvg !== ''
|
||||
? parseFloat(String(trackSpeedAvg))
|
||||
: undefined,
|
||||
events: (decrypted.events as LogEventPayload[]) || []
|
||||
})
|
||||
|
||||
return JSON.stringify({
|
||||
...payload,
|
||||
signSkipper: fingerprintSignature(decrypted.signSkipper),
|
||||
signCrew: fingerprintSignature(decrypted.signCrew)
|
||||
})
|
||||
}
|
||||
|
||||
interface LogEntryEditorProps {
|
||||
entryId: string
|
||||
logbookId: string
|
||||
@@ -44,24 +97,7 @@ interface LogEntryEditorProps {
|
||||
preloadedYacht?: any
|
||||
}
|
||||
|
||||
interface LogEvent {
|
||||
time: string
|
||||
mgk: string
|
||||
rwk: string
|
||||
windPressure: string
|
||||
windDirection: string
|
||||
windStrength: string
|
||||
seaState: string
|
||||
weatherIcon: string
|
||||
current: string
|
||||
heel: string
|
||||
sailsOrMotor: string
|
||||
logReading: string
|
||||
distance: string
|
||||
gpsLat: string
|
||||
gpsLng: string
|
||||
remarks: string
|
||||
}
|
||||
interface LogEvent extends LogEventPayload {}
|
||||
|
||||
export default function LogEntryEditor({
|
||||
entryId,
|
||||
@@ -75,6 +111,8 @@ export default function LogEntryEditor({
|
||||
}: LogEntryEditorProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { showAlert, showConfirm } = useDialog()
|
||||
const showAlertRef = useRef(showAlert)
|
||||
showAlertRef.current = showAlert
|
||||
|
||||
// General details state
|
||||
const [date, setDate] = useState('')
|
||||
@@ -136,6 +174,7 @@ export default function LogEntryEditor({
|
||||
const [success, setSuccess] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [weatherLoading, setWeatherLoading] = useState(false)
|
||||
const [savedFingerprint, setSavedFingerprint] = useState<string | null>(null)
|
||||
|
||||
// Track file upload
|
||||
const [savedTrack, setSavedTrack] = useState<SavedTrack | null>(null)
|
||||
@@ -144,6 +183,9 @@ export default function LogEntryEditor({
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const lockedContentHashRef = useRef<string | null>(null)
|
||||
const contentReadyRef = useRef(false)
|
||||
const lastSignatureAlertHashRef = useRef<string | null>(null)
|
||||
const skipCrewSignClearRef = useRef(false)
|
||||
const [editingEventIndex, setEditingEventIndex] = useState<number | null>(null)
|
||||
|
||||
const applyTrackStats = (waypoints: SavedTrack['waypoints']) => {
|
||||
const stats = computeTrackStats(waypoints)
|
||||
@@ -166,7 +208,7 @@ export default function LogEntryEditor({
|
||||
}
|
||||
}
|
||||
|
||||
const buildPayloadForSigning = useCallback(() => {
|
||||
const buildPayloadForSigning = useCallback((eventsOverride?: LogEvent[]) => {
|
||||
return buildLogEntryPayload({
|
||||
date,
|
||||
dayOfTravel,
|
||||
@@ -187,7 +229,7 @@ export default function LogEntryEditor({
|
||||
trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined,
|
||||
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
|
||||
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
|
||||
events
|
||||
events: eventsOverride ?? events
|
||||
})
|
||||
}, [
|
||||
date, dayOfTravel, departure, destination,
|
||||
@@ -197,6 +239,61 @@ export default function LogEntryEditor({
|
||||
events
|
||||
])
|
||||
|
||||
const currentFingerprint = useMemo(() => {
|
||||
const payload = buildPayloadForSigning()
|
||||
return JSON.stringify({
|
||||
...payload,
|
||||
signSkipper: fingerprintSignature(signSkipper),
|
||||
signCrew: fingerprintSignature(signCrew)
|
||||
})
|
||||
}, [buildPayloadForSigning, signSkipper, signCrew])
|
||||
|
||||
const isDirty = savedFingerprint !== null && currentFingerprint !== savedFingerprint
|
||||
|
||||
const persistEntryToDb = useCallback(async (eventsOverride?: LogEvent[]) => {
|
||||
if (readOnly) return
|
||||
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
|
||||
const entryData = {
|
||||
...buildPayloadForSigning(eventsOverride),
|
||||
signSkipper: normalizedSerializedSignature(signSkipper),
|
||||
signCrew: normalizedSerializedSignature(signCrew)
|
||||
}
|
||||
|
||||
const encrypted = await encryptJson(entryData, masterKey)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
await db.entries.put({
|
||||
payloadId: entryId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
await db.syncQueue.put({
|
||||
action: 'update',
|
||||
type: 'entry',
|
||||
payloadId: entryId,
|
||||
logbookId,
|
||||
data: JSON.stringify(encrypted),
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
|
||||
setSavedFingerprint(JSON.stringify({
|
||||
...buildPayloadForSigning(eventsOverride),
|
||||
signSkipper: fingerprintSignature(signSkipper),
|
||||
signCrew: fingerprintSignature(signCrew)
|
||||
}))
|
||||
}, [
|
||||
readOnly, logbookId, entryId, events, buildPayloadForSigning, signSkipper, signCrew
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
const handleOnline = () => setIsOnline(true)
|
||||
const handleOffline = () => setIsOnline(false)
|
||||
@@ -249,14 +346,21 @@ export default function LogEntryEditor({
|
||||
|
||||
if (entryHash !== lockedContentHashRef.current) {
|
||||
lockedContentHashRef.current = null
|
||||
setSignSkipper('')
|
||||
setSignCrew('')
|
||||
void showAlert(
|
||||
t('logs.sign_cleared_re_sign'),
|
||||
t('logs.sign_cleared_re_sign_title')
|
||||
)
|
||||
const hadSkipper = !!signSkipper
|
||||
const hadCrew = !!signCrew
|
||||
const skipperOnly = skipCrewSignClearRef.current
|
||||
skipCrewSignClearRef.current = false
|
||||
if (hadSkipper) setSignSkipper('')
|
||||
if (hadCrew && !skipperOnly) setSignCrew('')
|
||||
if (lastSignatureAlertHashRef.current !== entryHash && (hadSkipper || (hadCrew && !skipperOnly))) {
|
||||
lastSignatureAlertHashRef.current = entryHash
|
||||
void showAlertRef.current(
|
||||
skipperOnly ? t('logs.sign_cleared_skipper_re_sign') : t('logs.sign_cleared_re_sign'),
|
||||
skipperOnly ? t('logs.sign_cleared_skipper_re_sign_title') : t('logs.sign_cleared_re_sign_title')
|
||||
)
|
||||
}
|
||||
}
|
||||
}, [entryHash, signSkipper, signCrew, readOnly, showAlert, t])
|
||||
}, [entryHash, signSkipper, signCrew, readOnly, t])
|
||||
|
||||
const confirmSignWarning = useCallback(async (): Promise<boolean> => {
|
||||
return showConfirm(
|
||||
@@ -284,6 +388,7 @@ export default function LogEntryEditor({
|
||||
setSignSkipper(signature)
|
||||
setEntryHash(hash)
|
||||
lockedContentHashRef.current = hash
|
||||
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'skipper' })
|
||||
}
|
||||
|
||||
const handlePasskeySignCrew = async () => {
|
||||
@@ -300,6 +405,7 @@ export default function LogEntryEditor({
|
||||
setSignCrew(signature)
|
||||
setEntryHash(hash)
|
||||
lockedContentHashRef.current = hash
|
||||
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'crew' })
|
||||
}
|
||||
|
||||
// Auto-calculate Freshwater Consumption
|
||||
@@ -350,8 +456,10 @@ export default function LogEntryEditor({
|
||||
async function loadEntry() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setSavedFingerprint(null)
|
||||
lockedContentHashRef.current = null
|
||||
contentReadyRef.current = false
|
||||
lastSignatureAlertHashRef.current = null
|
||||
try {
|
||||
if (readOnly && preloadedEntry) {
|
||||
setDate(preloadedEntry.date || '')
|
||||
@@ -376,6 +484,7 @@ export default function LogEntryEditor({
|
||||
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
|
||||
loadTrackStatsFromEntry(preloadedEntry)
|
||||
setEvents(preloadedEntry.events || [])
|
||||
setSavedFingerprint(fingerprintFromStoredEntry(preloadedEntry))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -408,6 +517,7 @@ export default function LogEntryEditor({
|
||||
setSignCrew(normalizeSignature(decrypted.signCrew) || '')
|
||||
loadTrackStatsFromEntry(decrypted)
|
||||
setEvents(decrypted.events || [])
|
||||
setSavedFingerprint(fingerprintFromStoredEntry(decrypted))
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
@@ -423,7 +533,12 @@ export default function LogEntryEditor({
|
||||
|
||||
const loadTrack = async () => {
|
||||
if (readOnly && preloadedTrack) {
|
||||
setSavedTrack(preloadedTrack)
|
||||
setSavedTrack({
|
||||
waypoints: preloadedTrack.waypoints ?? [],
|
||||
gpxContent: preloadedTrack.gpxContent ?? '',
|
||||
filename: preloadedTrack.filename ?? 'track.gpx',
|
||||
fileType: preloadedTrack.fileType ?? 'gpx'
|
||||
})
|
||||
return
|
||||
}
|
||||
try {
|
||||
@@ -465,6 +580,7 @@ export default function LogEntryEditor({
|
||||
await saveUploadedTrack(logbookId, entryId, text, parsedWps, file.name, fileType)
|
||||
applyTrackStats(parsedWps)
|
||||
await loadTrack()
|
||||
trackPlausibleEvent(PlausibleEvents.GPS_TRACK_UPLOADED)
|
||||
} catch (err: any) {
|
||||
console.error('File parsing failed:', err)
|
||||
setUploadError(err.message || 'Failed to parse track file.')
|
||||
@@ -525,24 +641,19 @@ export default function LogEntryEditor({
|
||||
return
|
||||
}
|
||||
|
||||
const apiKey = localStorage.getItem('owm_api_key')
|
||||
if (!apiKey) {
|
||||
showAlert('GPS capturing failed, and no OpenWeatherMap API key is configured to perform location lookup.')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(locationQuery)}&appid=${apiKey}&units=metric`
|
||||
)
|
||||
if (!res.ok) throw new Error('Location not found')
|
||||
const data = await res.json()
|
||||
if (data.coord) {
|
||||
setEvGpsLat(Number(data.coord.lat).toFixed(6))
|
||||
setEvGpsLng(Number(data.coord.lon).toFixed(6))
|
||||
const data = await fetchOpenWeatherCurrent({ q: locationQuery })
|
||||
const coord = data.coord as { lat?: number; lon?: number } | undefined
|
||||
if (coord?.lat !== undefined && coord?.lon !== undefined) {
|
||||
setEvGpsLat(Number(coord.lat).toFixed(6))
|
||||
setEvGpsLng(Number(coord.lon).toFixed(6))
|
||||
showAlert(`Coordinates loaded for "${locationQuery}" via OpenWeatherMap.`)
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof WeatherApiError && e.code === 'NO_KEY') {
|
||||
showAlert(t('settings.no_key'))
|
||||
return
|
||||
}
|
||||
showAlert('Failed to retrieve GPS location or look up coordinates by location name.')
|
||||
}
|
||||
}
|
||||
@@ -581,35 +692,26 @@ export default function LogEntryEditor({
|
||||
return
|
||||
}
|
||||
|
||||
const apiKey = localStorage.getItem('owm_api_key')
|
||||
if (!apiKey) {
|
||||
showAlert(t('settings.no_key'))
|
||||
return
|
||||
}
|
||||
|
||||
setWeatherLoading(true)
|
||||
try {
|
||||
let url = ''
|
||||
if (hasGps) {
|
||||
url = `https://api.openweathermap.org/data/2.5/weather?lat=${evGpsLat}&lon=${evGpsLng}&appid=${apiKey}&units=metric`
|
||||
} else {
|
||||
url = `https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(fallbackLocation)}&appid=${apiKey}&units=metric`
|
||||
}
|
||||
|
||||
const res = await fetch(url)
|
||||
|
||||
if (!res.ok) throw new Error('Weather API rejected the request')
|
||||
|
||||
const data = await res.json()
|
||||
const data = await fetchOpenWeatherCurrent(
|
||||
hasGps
|
||||
? { lat: evGpsLat, lon: evGpsLng }
|
||||
: { q: fallbackLocation }
|
||||
)
|
||||
|
||||
const coord = data.coord as { lat?: number; lon?: number } | undefined
|
||||
// If fetched by location, automatically pre-fill GPS coordinates
|
||||
if (!hasGps && data.coord) {
|
||||
setEvGpsLat(Number(data.coord.lat).toFixed(6))
|
||||
setEvGpsLng(Number(data.coord.lon).toFixed(6))
|
||||
if (!hasGps && coord?.lat !== undefined && coord?.lon !== undefined) {
|
||||
setEvGpsLat(Number(coord.lat).toFixed(6))
|
||||
setEvGpsLng(Number(coord.lon).toFixed(6))
|
||||
}
|
||||
|
||||
const wind = data.wind as { speed?: number; deg?: number } | undefined
|
||||
const main = data.main as { pressure?: number } | undefined
|
||||
|
||||
// Convert wind speed m/s to Beaufort scale
|
||||
const mps = data.wind.speed || 0
|
||||
const mps = wind?.speed || 0
|
||||
let bft = 0
|
||||
if (mps < 0.3) bft = 0
|
||||
else if (mps < 1.6) bft = 1
|
||||
@@ -626,22 +728,27 @@ export default function LogEntryEditor({
|
||||
else bft = 12
|
||||
|
||||
setEvWindStrength(`${bft} Bft (${mps.toFixed(1)} m/s)`)
|
||||
setEvWindPressure(String(data.main.pressure || ''))
|
||||
setEvWindPressure(String(main?.pressure || ''))
|
||||
|
||||
// Calculate wind compass direction sector
|
||||
if (data.wind.deg !== undefined) {
|
||||
const deg = data.wind.deg
|
||||
if (wind?.deg !== undefined) {
|
||||
const deg = wind.deg
|
||||
const sectors = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW']
|
||||
const index = Math.round(deg / 22.5) % 16
|
||||
setEvWindDirection(sectors[index])
|
||||
}
|
||||
|
||||
if (data.weather && data.weather[0]) {
|
||||
setEvWeatherIcon(data.weather[0].icon)
|
||||
if (data.weather && Array.isArray(data.weather) && data.weather[0]) {
|
||||
const first = data.weather[0] as { icon?: string }
|
||||
if (first.icon) setEvWeatherIcon(first.icon)
|
||||
}
|
||||
|
||||
showAlert(t('settings.weather_success'))
|
||||
} catch (err) {
|
||||
if (err instanceof WeatherApiError && err.code === 'NO_KEY') {
|
||||
showAlert(t('settings.no_key'))
|
||||
return
|
||||
}
|
||||
console.error('Weather prefilling failed:', err)
|
||||
showAlert(t('settings.weather_error'))
|
||||
} finally {
|
||||
@@ -676,32 +783,26 @@ export default function LogEntryEditor({
|
||||
return currentItems.includes(item.toLowerCase())
|
||||
}
|
||||
|
||||
const handleAddEvent = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (readOnly || !evTime) return
|
||||
const buildEventFromForm = (): LogEvent => ({
|
||||
time: evTime,
|
||||
mgk: evMgk.trim(),
|
||||
rwk: evRwk.trim(),
|
||||
windPressure: evWindPressure.trim(),
|
||||
windDirection: evWindDirection.trim(),
|
||||
windStrength: evWindStrength.trim(),
|
||||
seaState: evSeaState.trim(),
|
||||
weatherIcon: evWeatherIcon.trim(),
|
||||
current: evCurrent.trim(),
|
||||
heel: evHeel.trim(),
|
||||
sailsOrMotor: evSailsOrMotor.trim(),
|
||||
logReading: evLogReading.trim(),
|
||||
distance: evDistance.trim(),
|
||||
gpsLat: evGpsLat.trim(),
|
||||
gpsLng: evGpsLng.trim(),
|
||||
remarks: evRemarks.trim()
|
||||
})
|
||||
|
||||
const newEvent: LogEvent = {
|
||||
time: evTime,
|
||||
mgk: evMgk.trim(),
|
||||
rwk: evRwk.trim(),
|
||||
windPressure: evWindPressure.trim(),
|
||||
windDirection: evWindDirection.trim(),
|
||||
windStrength: evWindStrength.trim(),
|
||||
seaState: evSeaState.trim(),
|
||||
weatherIcon: evWeatherIcon.trim(),
|
||||
current: evCurrent.trim(),
|
||||
heel: evHeel.trim(),
|
||||
sailsOrMotor: evSailsOrMotor.trim(),
|
||||
logReading: evLogReading.trim(),
|
||||
distance: evDistance.trim(),
|
||||
gpsLat: evGpsLat.trim(),
|
||||
gpsLng: evGpsLng.trim(),
|
||||
remarks: evRemarks.trim()
|
||||
}
|
||||
|
||||
setEvents((prev) => [...prev, newEvent])
|
||||
|
||||
// Clear event form fields
|
||||
const clearEventForm = () => {
|
||||
setEvTime('')
|
||||
setEvMgk('')
|
||||
setEvRwk('')
|
||||
@@ -719,11 +820,103 @@ export default function LogEntryEditor({
|
||||
setEvGpsLng('')
|
||||
setEvRemarks('')
|
||||
setEvLocationName('')
|
||||
setEditingEventIndex(null)
|
||||
}
|
||||
|
||||
const handleDeleteEvent = (index: number) => {
|
||||
const fillEventForm = (ev: LogEvent) => {
|
||||
setEvTime(ev.time)
|
||||
setEvMgk(ev.mgk)
|
||||
setEvRwk(ev.rwk)
|
||||
setEvWindPressure(ev.windPressure)
|
||||
setEvWindDirection(ev.windDirection)
|
||||
setEvWindStrength(ev.windStrength)
|
||||
setEvSeaState(ev.seaState)
|
||||
setEvWeatherIcon(ev.weatherIcon)
|
||||
setEvCurrent(ev.current)
|
||||
setEvHeel(ev.heel)
|
||||
setEvSailsOrMotor(ev.sailsOrMotor)
|
||||
setEvLogReading(ev.logReading)
|
||||
setEvDistance(ev.distance)
|
||||
setEvGpsLat(ev.gpsLat)
|
||||
setEvGpsLng(ev.gpsLng)
|
||||
setEvRemarks(ev.remarks)
|
||||
setEvLocationName('')
|
||||
}
|
||||
|
||||
const markSkipperSignatureClearedForEventChange = () => {
|
||||
if (!signSkipper) return
|
||||
skipCrewSignClearRef.current = true
|
||||
setSignSkipper('')
|
||||
}
|
||||
|
||||
const handleEditEvent = (index: number) => {
|
||||
if (readOnly) return
|
||||
setEvents((prev) => prev.filter((_, idx) => idx !== index))
|
||||
const ev = events[index]
|
||||
if (!ev) return
|
||||
fillEventForm(ev)
|
||||
setEditingEventIndex(index)
|
||||
}
|
||||
|
||||
const handleCancelEventEdit = () => {
|
||||
clearEventForm()
|
||||
}
|
||||
|
||||
const handleSaveEvent = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (readOnly || !evTime) return
|
||||
|
||||
const eventData = buildEventFromForm()
|
||||
let nextEvents: LogEvent[]
|
||||
|
||||
if (editingEventIndex !== null) {
|
||||
const hadSkipperSignature = !!signSkipper
|
||||
markSkipperSignatureClearedForEventChange()
|
||||
nextEvents = events.map((ev, idx) => (idx === editingEventIndex ? eventData : ev))
|
||||
if (hadSkipperSignature) {
|
||||
void showAlertRef.current(
|
||||
t('logs.sign_cleared_skipper_re_sign'),
|
||||
t('logs.sign_cleared_skipper_re_sign_title')
|
||||
)
|
||||
}
|
||||
} else {
|
||||
nextEvents = [...events, eventData]
|
||||
}
|
||||
|
||||
setEvents(nextEvents)
|
||||
clearEventForm()
|
||||
|
||||
try {
|
||||
await persistEntryToDb(nextEvents)
|
||||
} catch (err: any) {
|
||||
console.error('Failed to auto-save event:', err)
|
||||
setError(err.message || 'Failed to save event.')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteEvent = async (index: number) => {
|
||||
if (readOnly) return
|
||||
const hadSkipperSignature = !!signSkipper
|
||||
markSkipperSignatureClearedForEventChange()
|
||||
const nextEvents = events.filter((_, idx) => idx !== index)
|
||||
setEvents(nextEvents)
|
||||
if (hadSkipperSignature) {
|
||||
void showAlertRef.current(
|
||||
t('logs.sign_cleared_skipper_re_sign'),
|
||||
t('logs.sign_cleared_skipper_re_sign_title')
|
||||
)
|
||||
}
|
||||
if (editingEventIndex === index) {
|
||||
clearEventForm()
|
||||
} else if (editingEventIndex !== null && index < editingEventIndex) {
|
||||
setEditingEventIndex(editingEventIndex - 1)
|
||||
}
|
||||
|
||||
try {
|
||||
await persistEntryToDb(nextEvents)
|
||||
} catch (err: any) {
|
||||
console.error('Failed to auto-save after event delete:', err)
|
||||
setError(err.message || 'Failed to save event deletion.')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownloadPdf = async () => {
|
||||
@@ -731,6 +924,7 @@ export default function LogEntryEditor({
|
||||
setError(null)
|
||||
try {
|
||||
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.')
|
||||
@@ -741,53 +935,20 @@ export default function LogEntryEditor({
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (readOnly) return
|
||||
if (readOnly || !isDirty) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setSuccess(false)
|
||||
|
||||
try {
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
|
||||
const entryPayload = buildPayloadForSigning()
|
||||
const entryData = {
|
||||
...entryPayload,
|
||||
signSkipper: serializeSignature(signSkipper),
|
||||
signCrew: serializeSignature(signCrew)
|
||||
}
|
||||
|
||||
// E2E encrypt
|
||||
const encrypted = await encryptJson(entryData, masterKey)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// Save locally
|
||||
await db.entries.put({
|
||||
payloadId: entryId,
|
||||
logbookId,
|
||||
encryptedData: encrypted.ciphertext,
|
||||
iv: encrypted.iv,
|
||||
tag: encrypted.tag,
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
// Queue for background sync
|
||||
await db.syncQueue.put({
|
||||
action: 'update',
|
||||
type: 'entry',
|
||||
payloadId: entryId,
|
||||
logbookId,
|
||||
data: JSON.stringify(encrypted),
|
||||
updatedAt: now
|
||||
})
|
||||
await persistEntryToDb()
|
||||
|
||||
setSuccess(true)
|
||||
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
|
||||
setTimeout(() => {
|
||||
setSuccess(false)
|
||||
onBack()
|
||||
}, 1500)
|
||||
|
||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||
} catch (err: any) {
|
||||
console.error('Failed to save entry details:', err)
|
||||
setError(err.message || 'Failed to save entry details.')
|
||||
@@ -1061,8 +1222,22 @@ export default function LogEntryEditor({
|
||||
</td>
|
||||
<td className="remarks-td">{ev.remarks}</td>
|
||||
{!readOnly && (
|
||||
<td>
|
||||
<button type="button" className="btn-icon logout" onClick={() => handleDeleteEvent(idx)}>
|
||||
<td className="events-actions-td">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon"
|
||||
onClick={() => handleEditEvent(idx)}
|
||||
title={t('logs.edit_event')}
|
||||
disabled={editingEventIndex !== null && editingEventIndex !== idx}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon logout"
|
||||
onClick={() => handleDeleteEvent(idx)}
|
||||
title={t('logs.delete_event')}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</td>
|
||||
@@ -1077,7 +1252,9 @@ export default function LogEntryEditor({
|
||||
{/* Add New Event Form Sub-Card */}
|
||||
{!readOnly && (
|
||||
<div className="member-editor-card glass">
|
||||
<h4 style={{ margin: '0 0 16px 0', color: '#fbbf24' }}>{t('logs.add_event')}</h4>
|
||||
<h4 style={{ margin: '0 0 16px 0', color: '#fbbf24' }}>
|
||||
{editingEventIndex !== null ? t('logs.edit_event') : t('logs.add_event')}
|
||||
</h4>
|
||||
|
||||
<div className="form-grid mb-4">
|
||||
<div className="input-group">
|
||||
@@ -1311,22 +1488,36 @@ export default function LogEntryEditor({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={handleAddEvent}
|
||||
disabled={saving || !evTime}
|
||||
style={{ width: 'auto', padding: '10px 20px', marginLeft: 'auto', display: 'flex' }}
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add Event Entry
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: '8px', marginLeft: 'auto', flexWrap: 'wrap' }}>
|
||||
{editingEventIndex !== null && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={handleCancelEventEdit}
|
||||
disabled={saving}
|
||||
style={{ width: 'auto', padding: '10px 20px', display: 'flex' }}
|
||||
>
|
||||
<X size={16} />
|
||||
{t('logs.cancel_event_edit')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={handleSaveEvent}
|
||||
disabled={saving || !evTime}
|
||||
style={{ width: 'auto', padding: '10px 20px', display: 'flex' }}
|
||||
>
|
||||
{editingEventIndex !== null ? <Save size={16} /> : <Plus size={16} />}
|
||||
{editingEventIndex !== null ? t('logs.save_event_btn') : t('logs.add_event_btn')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Track file upload */}
|
||||
<div className="form-card">
|
||||
<div className="form-card" data-tour="entry-track">
|
||||
<div className="form-header">
|
||||
<Upload size={20} className="form-icon" />
|
||||
<h3>{t('logs.track_upload_title')}</h3>
|
||||
@@ -1361,7 +1552,7 @@ export default function LogEntryEditor({
|
||||
<Upload size={16} style={{ color: '#fbbf24' }} />
|
||||
<span className="track-info-name">{savedTrack.filename || 'track'}</span>
|
||||
<span className="track-info-stats">
|
||||
{savedTrack.fileType.toUpperCase()}
|
||||
{(savedTrack.fileType ?? 'gpx').toUpperCase()}
|
||||
{savedTrack.waypoints.length > 0 && (
|
||||
<> · {savedTrack.waypoints.length} {t('logs.track_upload_points')}</>
|
||||
)}
|
||||
@@ -1477,7 +1668,7 @@ export default function LogEntryEditor({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="submit" className="btn primary" disabled={saving || !date || !dayOfTravel.trim()}>
|
||||
<button type="submit" className="btn primary" disabled={saving || !date || !dayOfTravel.trim() || !isDirty}>
|
||||
<Save size={18} />
|
||||
{saving ? t('logs.saving') : t('logs.save')}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
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'
|
||||
|
||||
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 } = 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 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>
|
||||
|
||||
<div className="input-group">
|
||||
<label htmlFor="backup-export-passphrase">{t('settings.backup_passphrase')}</label>
|
||||
<input
|
||||
id="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}
|
||||
/>
|
||||
</div>
|
||||
<div className="input-group">
|
||||
<label htmlFor="backup-export-confirm">{t('settings.backup_passphrase_confirm')}</label>
|
||||
<input
|
||||
id="backup-export-confirm"
|
||||
type="password"
|
||||
className="input-text"
|
||||
value={exportConfirm}
|
||||
onChange={(e) => setExportConfirm(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
disabled={exporting}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
onClick={handleExport}
|
||||
disabled={exporting || !exportPassphrase || !exportConfirm}
|
||||
>
|
||||
<Download size={16} />
|
||||
{exporting ? t('settings.backup_exporting') : t('settings.backup_export_btn')}
|
||||
</button>
|
||||
</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>
|
||||
|
||||
<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"
|
||||
type="password"
|
||||
className="input-text"
|
||||
value={importPassphrase}
|
||||
onChange={(e) => {
|
||||
setImportPassphrase(e.target.value)
|
||||
setImportPreview(null)
|
||||
}}
|
||||
autoComplete="current-password"
|
||||
disabled={importing}
|
||||
/>
|
||||
</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="button"
|
||||
className="btn primary"
|
||||
onClick={() => handleRestore()}
|
||||
disabled={importing || !importPassphrase}
|
||||
>
|
||||
<Upload size={16} />
|
||||
{importing ? t('settings.backup_restoring') : t('settings.backup_restore_btn')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{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: new Date(importPreview.exportedAt).toLocaleString()
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,10 +3,14 @@ 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 LogbookRoleBadge from './LogbookRoleBadge.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
|
||||
@@ -69,6 +73,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 +84,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 {
|
||||
@@ -103,6 +108,68 @@ 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) => (
|
||||
<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">
|
||||
<h3>{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>
|
||||
|
||||
<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>
|
||||
)
|
||||
|
||||
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 */}
|
||||
@@ -139,9 +206,13 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
||||
</div>
|
||||
|
||||
{/* Skipper profile */}
|
||||
<div className="skipper-badge">
|
||||
<User size={16} />
|
||||
<span>{username}</span>
|
||||
<div
|
||||
className="skipper-badge"
|
||||
title={t('dashboard.logged_in_as', { name: username })}
|
||||
aria-label={t('dashboard.logged_in_as', { name: username })}
|
||||
>
|
||||
<User size={16} aria-hidden="true" />
|
||||
<span className="skipper-badge__name">{username}</span>
|
||||
</div>
|
||||
|
||||
{/* Lang toggle */}
|
||||
@@ -149,6 +220,10 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
||||
<Languages size={18} />
|
||||
</button>
|
||||
|
||||
<DisclaimerHeaderButton />
|
||||
|
||||
<FeedbackHeaderButton />
|
||||
|
||||
{/* Logout */}
|
||||
<button className="btn-icon logout" onClick={handleLogout} title={t('dashboard.logout')}>
|
||||
<LogOut size={18} />
|
||||
@@ -196,39 +271,16 @@ 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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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('settings.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('settings.push_title')}</h3>
|
||||
</div>
|
||||
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: 0 }}>
|
||||
{t('settings.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('settings.push_title')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: '0 0 16px 0' }}>
|
||||
{t('settings.push_desc')}
|
||||
</p>
|
||||
|
||||
{iosNeedsInstall && (
|
||||
<p className="text-muted" style={{ fontSize: '13px', margin: '0 0 12px 0' }}>
|
||||
{t('settings.push_ios_install_hint')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{permission === 'denied' && (
|
||||
<p style={{ fontSize: '13px', color: '#f87171', margin: '0 0 12px 0' }}>
|
||||
{t('settings.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('settings.push_enable')}</span>
|
||||
</label>
|
||||
|
||||
{enabled && permission === 'granted' && (
|
||||
<p className="text-muted" style={{ fontSize: '12px', margin: '12px 0 0 0' }}>
|
||||
{t('settings.push_active')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RefreshCw, X } from 'lucide-react'
|
||||
import { usePwaUpdate } from '../hooks/usePwaUpdate.js'
|
||||
|
||||
export default function PwaUpdatePrompt() {
|
||||
const { t } = useTranslation()
|
||||
const { needRefresh, updateApp, dismissUpdate } = usePwaUpdate()
|
||||
const [updating, setUpdating] = useState(false)
|
||||
|
||||
if (!needRefresh) return null
|
||||
|
||||
const handleUpdate = async () => {
|
||||
setUpdating(true)
|
||||
try {
|
||||
await updateApp()
|
||||
} finally {
|
||||
setUpdating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pwa-update-banner" role="alert" aria-live="polite">
|
||||
<div className="pwa-update-icon" aria-hidden="true">
|
||||
<RefreshCw size={22} />
|
||||
</div>
|
||||
|
||||
<div className="pwa-update-body">
|
||||
<p className="pwa-update-title">{t('pwa.update_title')}</p>
|
||||
<p className="pwa-update-text">{t('pwa.update_desc')}</p>
|
||||
</div>
|
||||
|
||||
<div className="pwa-update-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary pwa-update-btn"
|
||||
onClick={handleUpdate}
|
||||
disabled={updating}
|
||||
>
|
||||
{updating ? t('pwa.update_reloading') : t('pwa.update_now')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="pwa-update-link"
|
||||
onClick={dismissUpdate}
|
||||
>
|
||||
{t('pwa.later')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="pwa-update-close"
|
||||
onClick={dismissUpdate}
|
||||
aria-label={t('pwa.later')}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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,13 +1,20 @@
|
||||
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, Save, Check, Users, Trash2, Copy, Link as LinkIcon, Compass } from 'lucide-react'
|
||||
import { ensureLogbookKey } from '../services/logbookKeys.js'
|
||||
import LogbookBackupPanel from './LogbookBackupPanel.tsx'
|
||||
import AccountDangerZone from './AccountDangerZone.tsx'
|
||||
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
|
||||
import PushNotificationSettings from './PushNotificationSettings.tsx'
|
||||
import { useDialog } from './ModalDialog.tsx'
|
||||
import { notifyAppearanceChanged } from '../services/appearance.js'
|
||||
import ThemedSelect from './ThemedSelect.tsx'
|
||||
import { useAppTour } from '../context/AppTourContext.tsx'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
|
||||
interface SettingsFormProps {
|
||||
logbookId?: string | null
|
||||
onLogbookRestored?: (logbookId: string, title: string) => void
|
||||
}
|
||||
|
||||
interface Collaborator {
|
||||
@@ -25,11 +32,13 @@ const bufferToHex = (buffer: ArrayBuffer): string => {
|
||||
.join('')
|
||||
}
|
||||
|
||||
export default function SettingsForm({ logbookId }: SettingsFormProps) {
|
||||
export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const { showConfirm, showAlert } = useDialog()
|
||||
const { restartTour } = useAppTour()
|
||||
const [apiKey, setApiKey] = useState(localStorage.getItem('owm_api_key') || '')
|
||||
const [theme, setTheme] = useState(localStorage.getItem('active_theme') || 'auto')
|
||||
const [colorScheme, setColorScheme] = useState(localStorage.getItem('active_color_scheme') || 'auto')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
@@ -203,6 +212,7 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
|
||||
const link = `${window.location.origin}/invite?token=${invite.token}#key=${hexKey}`
|
||||
|
||||
setInviteLink(link)
|
||||
trackPlausibleEvent(PlausibleEvents.INVITE_GENERATED)
|
||||
} catch (err: any) {
|
||||
console.error('Failed to generate invite:', err)
|
||||
showAlert(err.message || 'Failed to generate invite link.')
|
||||
@@ -245,17 +255,29 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem('owm_api_key', apiKey.trim())
|
||||
localStorage.setItem('active_theme', theme)
|
||||
|
||||
// Notify App of theme change
|
||||
window.dispatchEvent(new Event('theme-changed'))
|
||||
persistAppearance(theme, colorScheme)
|
||||
|
||||
setSaving(false)
|
||||
setSuccess(true)
|
||||
@@ -276,6 +298,7 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
|
||||
|
||||
<form onSubmit={handleSubmit} className="vessel-form mt-6">
|
||||
<PwaInstallPrompt variant="inline" />
|
||||
<PushNotificationSettings />
|
||||
|
||||
{/* Weather Integration card */}
|
||||
<div className="member-editor-card glass">
|
||||
@@ -312,22 +335,63 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
|
||||
</p>
|
||||
|
||||
<div className="input-group">
|
||||
<select
|
||||
<ThemedSelect
|
||||
id="app-theme"
|
||||
className="input-text"
|
||||
value={theme}
|
||||
onChange={(e) => setTheme(e.target.value)}
|
||||
disabled={saving}
|
||||
style={{ background: 'rgba(11, 12, 16, 0.85)', color: '#f1f5f9' }}
|
||||
>
|
||||
<option value="auto">{t('settings.theme_auto')}</option>
|
||||
<option value="ocean">{t('settings.theme_ocean')}</option>
|
||||
<option value="material">{t('settings.theme_material')}</option>
|
||||
<option value="cupertino">{t('settings.theme_cupertino')}</option>
|
||||
</select>
|
||||
onChange={handleThemeChange}
|
||||
options={[
|
||||
{ value: 'auto', label: t('settings.theme_auto') },
|
||||
{ value: 'ocean', label: t('settings.theme_ocean') },
|
||||
{ value: 'material', label: t('settings.theme_material') },
|
||||
{ value: 'cupertino', label: t('settings.theme_cupertino') }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="member-editor-card glass mt-4">
|
||||
<h3 style={{ marginTop: 0, marginBottom: '12px', color: 'var(--app-accent-light)', fontSize: '16px' }}>
|
||||
{t('settings.color_scheme_title')}
|
||||
</h3>
|
||||
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: '0 0 16px 0' }}>
|
||||
{t('settings.color_scheme_label')}
|
||||
</p>
|
||||
|
||||
<div className="input-group">
|
||||
<ThemedSelect
|
||||
id="app-color-scheme"
|
||||
value={colorScheme}
|
||||
disabled={saving}
|
||||
onChange={handleColorSchemeChange}
|
||||
options={[
|
||||
{ value: 'auto', label: t('settings.color_scheme_auto') },
|
||||
{ value: 'light', label: t('settings.color_scheme_light') },
|
||||
{ value: 'dark', label: t('settings.color_scheme_dark') }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="member-editor-card glass mt-4">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
|
||||
<Compass size={20} style={{ color: 'var(--app-accent-light)' }} />
|
||||
<h3 style={{ margin: 0, color: 'var(--app-accent-light)', fontSize: '16px' }}>
|
||||
{t('settings.tour_title')}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: '0 0 16px 0' }}>
|
||||
{t('settings.tour_desc')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary"
|
||||
onClick={() => restartTour()}
|
||||
>
|
||||
{t('settings.tour_restart')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="form-actions mt-4 mb-6">
|
||||
{success && (
|
||||
<div className="success-toast">
|
||||
@@ -394,6 +458,11 @@ export default function SettingsForm({ logbookId }: SettingsFormProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Backup & Restore (owner only) */}
|
||||
{logbookId && isOwner && (
|
||||
<LogbookBackupPanel logbookId={logbookId} onRestored={onLogbookRestored} />
|
||||
)}
|
||||
|
||||
{/* Crew Collaboration Card (Only visible to Logbook Owner) */}
|
||||
{logbookId && isOwner && (
|
||||
<div className="member-editor-card glass mt-6" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '24px' }}>
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { BarChart2, Anchor, Droplets, Fuel, Sailboat, Gauge } from 'lucide-react'
|
||||
import MultiTrackMap from './MultiTrackMap.tsx'
|
||||
import {
|
||||
formatLiters,
|
||||
formatNm,
|
||||
loadAccountStats,
|
||||
loadLogbookStats,
|
||||
type LogbookStatsSummary,
|
||||
type StatsTotals,
|
||||
type TravelDayStats
|
||||
} from '../services/statsAggregation.js'
|
||||
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.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={<Fuel size={20} />}
|
||||
label={t('stats.fuel_total')}
|
||||
value={formatLiters(totals.totalFuelL)}
|
||||
unit={t('stats.unit_l')}
|
||||
/>
|
||||
<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_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')}</>
|
||||
)}
|
||||
</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">
|
||||
<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.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>{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_consumption')}</h3>
|
||||
<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,86 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
|
||||
export interface ThemedSelectOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface ThemedSelectProps {
|
||||
id?: string
|
||||
value: string
|
||||
options: ThemedSelectOption[]
|
||||
onChange: (value: string) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export default function ThemedSelect({
|
||||
id,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
disabled = false
|
||||
}: ThemedSelectProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const selected = options.find((option) => option.value === value)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
const closeOnOutsideClick = (event: MouseEvent) => {
|
||||
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setOpen(false)
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', closeOnOutsideClick)
|
||||
document.addEventListener('keydown', closeOnEscape)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', closeOnOutsideClick)
|
||||
document.removeEventListener('keydown', closeOnEscape)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const selectOption = (nextValue: string) => {
|
||||
onChange(nextValue)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`themed-select${open ? ' is-open' : ''}`} ref={rootRef}>
|
||||
<button
|
||||
type="button"
|
||||
id={id}
|
||||
className="themed-select-trigger input-text"
|
||||
disabled={disabled}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
onClick={() => !disabled && setOpen((current) => !current)}
|
||||
>
|
||||
<span>{selected?.label ?? value}</span>
|
||||
<ChevronDown size={16} className="themed-select-chevron" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<ul className="themed-select-menu" role="listbox" aria-labelledby={id}>
|
||||
{options.map((option) => (
|
||||
<li
|
||||
key={option.value}
|
||||
role="option"
|
||||
aria-selected={option.value === value}
|
||||
className={`themed-select-option${option.value === value ? ' is-selected' : ''}`}
|
||||
onClick={() => selectOption(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -13,9 +14,30 @@ interface VesselFormProps {
|
||||
preloadedData?: any
|
||||
}
|
||||
|
||||
function metricInputFromStored(value: unknown): string {
|
||||
if (value == null || value === '') return ''
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
|
||||
if (typeof value === 'string') return value.trim()
|
||||
return ''
|
||||
}
|
||||
|
||||
function parseOptionalMetricMeters(input: string): number | undefined {
|
||||
const trimmed = input.trim().replace(',', '.')
|
||||
if (!trimmed) return undefined
|
||||
const parsed = Number(trimmed)
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
throw new Error('invalid_metric')
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
export default function VesselForm({ logbookId, readOnly = false, preloadedData }: VesselFormProps) {
|
||||
const { t } = useTranslation()
|
||||
const [name, setName] = useState('')
|
||||
const [vesselType, setVesselType] = useState<'sailing' | 'motor' | ''>('')
|
||||
const [lengthM, setLengthM] = useState('')
|
||||
const [draftM, setDraftM] = useState('')
|
||||
const [airDraftM, setAirDraftM] = useState('')
|
||||
const [homePort, setHomePort] = useState('')
|
||||
const [charterCompany, setCharterCompany] = useState('')
|
||||
const [owner, setOwner] = useState('')
|
||||
@@ -43,6 +65,10 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
|
||||
try {
|
||||
if (readOnly && preloadedData) {
|
||||
setName(preloadedData.name || '')
|
||||
setVesselType(preloadedData.vesselType || '')
|
||||
setLengthM(metricInputFromStored(preloadedData.lengthM))
|
||||
setDraftM(metricInputFromStored(preloadedData.draftM))
|
||||
setAirDraftM(metricInputFromStored(preloadedData.airDraftM))
|
||||
setHomePort(preloadedData.homePort || '')
|
||||
setCharterCompany(preloadedData.charterCompany || '')
|
||||
setOwner(preloadedData.owner || '')
|
||||
@@ -64,6 +90,10 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
|
||||
const decrypted = await decryptJson(local.encryptedData, local.iv, local.tag, masterKey)
|
||||
if (decrypted) {
|
||||
setName(decrypted.name || '')
|
||||
setVesselType(decrypted.vesselType || '')
|
||||
setLengthM(metricInputFromStored(decrypted.lengthM))
|
||||
setDraftM(metricInputFromStored(decrypted.draftM))
|
||||
setAirDraftM(metricInputFromStored(decrypted.airDraftM))
|
||||
setHomePort(decrypted.homePort || '')
|
||||
setCharterCompany(decrypted.charterCompany || '')
|
||||
setOwner(decrypted.owner || '')
|
||||
@@ -168,8 +198,25 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
|
||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||
|
||||
let parsedLengthM: number | undefined
|
||||
let parsedDraftM: number | undefined
|
||||
let parsedAirDraftM: number | undefined
|
||||
try {
|
||||
parsedLengthM = parseOptionalMetricMeters(lengthM)
|
||||
parsedDraftM = parseOptionalMetricMeters(draftM)
|
||||
parsedAirDraftM = parseOptionalMetricMeters(airDraftM)
|
||||
} catch {
|
||||
setError(t('vessel.invalid_metric'))
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
|
||||
const yachtData = {
|
||||
name: name.trim(),
|
||||
vesselType: vesselType || undefined,
|
||||
lengthM: parsedLengthM,
|
||||
draftM: parsedDraftM,
|
||||
airDraftM: parsedAirDraftM,
|
||||
homePort: homePort.trim(),
|
||||
charterCompany: charterCompany.trim(),
|
||||
owner: owner.trim(),
|
||||
@@ -205,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
|
||||
@@ -302,6 +350,59 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.type')}</label>
|
||||
<select
|
||||
className="input-text"
|
||||
value={vesselType}
|
||||
onChange={(e) => setVesselType(e.target.value as 'sailing' | 'motor' | '')}
|
||||
disabled={saving || readOnly}
|
||||
>
|
||||
<option value="">{t('vessel.type_unset')}</option>
|
||||
<option value="sailing">{t('vessel.type_sailing')}</option>
|
||||
<option value="motor">{t('vessel.type_motor')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.length_m')}</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="input-text"
|
||||
value={lengthM}
|
||||
onChange={(e) => setLengthM(e.target.value)}
|
||||
disabled={saving || readOnly}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.draft_m')}</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="input-text"
|
||||
value={draftM}
|
||||
onChange={(e) => setDraftM(e.target.value)}
|
||||
disabled={saving || readOnly}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.air_draft_m')}</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="input-text"
|
||||
value={airDraftM}
|
||||
onChange={(e) => setAirDraftM(e.target.value)}
|
||||
disabled={saving || readOnly}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input-group">
|
||||
<label>{t('vessel.port')}</label>
|
||||
<input
|
||||
|
||||
@@ -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,286 @@
|
||||
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'
|
||||
| 'finish'
|
||||
|
||||
interface TourNavigation {
|
||||
setActiveTab: (tab: AppTab) => void
|
||||
setSelectedEntryId: (entryId: string | null) => 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 STEP_ORDER: TourStepId[] = [
|
||||
'welcome',
|
||||
'nav_logs',
|
||||
'entry_list',
|
||||
'entry_open',
|
||||
'entry_track',
|
||||
'nav_vessel',
|
||||
'nav_crew',
|
||||
'finish'
|
||||
]
|
||||
|
||||
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"]'
|
||||
}
|
||||
|
||||
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 currentStepId = isActive ? STEP_ORDER[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')
|
||||
}
|
||||
}, [resolveFirstEntryId])
|
||||
|
||||
const scrollToCurrentTarget = useCallback((stepId: TourStepId | null) => {
|
||||
if (!stepId) return
|
||||
const selector = TARGET_BY_STEP[stepId]
|
||||
if (!selector) return
|
||||
window.requestAnimationFrame(() => {
|
||||
const el = document.querySelector(selector)
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' })
|
||||
})
|
||||
}, [])
|
||||
|
||||
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)
|
||||
} else {
|
||||
const step = STEP_ORDER[stepIndexAtDismiss] ?? 'welcome'
|
||||
trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_SKIPPED, { step, ...tourProps })
|
||||
}
|
||||
|
||||
tourModeRef.current = { demoMode: 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(() => {
|
||||
if (stepIndex + 1 >= STEP_ORDER.length) {
|
||||
dismissTour('completed', stepIndex)
|
||||
return
|
||||
}
|
||||
setStepIndex(stepIndex + 1)
|
||||
}, [dismissTour, stepIndex])
|
||||
|
||||
const prevStep = useCallback(() => {
|
||||
setStepIndex((current) => Math.max(0, current - 1))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return
|
||||
const stepId = STEP_ORDER[stepIndex]
|
||||
if (!stepId) return
|
||||
applyStepSideEffects(stepId)
|
||||
scrollToCurrentTarget(stepId)
|
||||
}, [isActive, 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: STEP_ORDER.length,
|
||||
startTour,
|
||||
stopTour,
|
||||
restartTour,
|
||||
nextStep,
|
||||
prevStep,
|
||||
skipTour,
|
||||
registerNavigation,
|
||||
registerDemoTourContext,
|
||||
requestStartAfterLogin
|
||||
}),
|
||||
[
|
||||
currentStepId,
|
||||
isActive,
|
||||
isDemoTour,
|
||||
nextStep,
|
||||
prevStep,
|
||||
registerDemoTourContext,
|
||||
registerNavigation,
|
||||
requestStartAfterLogin,
|
||||
restartTour,
|
||||
skipTour,
|
||||
startTour,
|
||||
stepIndex,
|
||||
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,102 @@
|
||||
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
|
||||
|
||||
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(() => {})
|
||||
}
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
checkForUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
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, setNeedRefresh],
|
||||
updateServiceWorker
|
||||
} = useRegisterSW({
|
||||
immediate: true,
|
||||
onNeedReload() {
|
||||
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)
|
||||
}
|
||||
|
||||
const dismissUpdate = () => {
|
||||
setNeedRefresh(false)
|
||||
suppressUpdatePrompt(UPDATE_DISMISS_SUPPRESS_MS)
|
||||
}
|
||||
|
||||
return { needRefresh, updateApp, dismissUpdate }
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
"crew": "Crew-Liste",
|
||||
"deviation": "Ablenkungstabelle",
|
||||
"logs": "Logbucheinträge",
|
||||
"stats": "Statistik",
|
||||
"settings": "Einstellungen"
|
||||
},
|
||||
"auth": {
|
||||
@@ -37,6 +38,7 @@
|
||||
"error_incorrect_recovery": "Falscher Wiederherstellungsschlüssel. Entschlüsselung fehlgeschlagen.",
|
||||
"error_decryption_failed": "Entschlüsselung fehlgeschlagen. Bitte überprüfen Sie Ihren Wiederherstellungsschlüssel.",
|
||||
"or_register": "oder Registrieren",
|
||||
"explore_demo": "Demo ohne Account erkunden",
|
||||
"username_placeholder": "Benutzername / Skippername",
|
||||
"processing": "Verarbeitung...",
|
||||
"help": "Hilfe",
|
||||
@@ -66,7 +68,11 @@
|
||||
"platform_ios": "Installation über Safari",
|
||||
"platform_android": "Installation über den Browser",
|
||||
"platform_desktop": "Installation als Desktop-App",
|
||||
"settings_section": "App-Installation"
|
||||
"settings_section": "App-Installation",
|
||||
"update_title": "Update verfügbar",
|
||||
"update_desc": "Eine neue Version von Kapteins Daagbok ist bereit. Bitte aktualisieren, um die neuesten Änderungen zu erhalten.",
|
||||
"update_now": "Jetzt aktualisieren",
|
||||
"update_reloading": "Wird geladen…"
|
||||
},
|
||||
"sync": {
|
||||
"status_synced": "Synchronisiert",
|
||||
@@ -76,6 +82,14 @@
|
||||
"vessel": {
|
||||
"title": "Schiffs-Stammdaten",
|
||||
"name": "Yachtname",
|
||||
"type": "Yachttyp",
|
||||
"type_unset": "— nicht angegeben —",
|
||||
"type_sailing": "Segelyacht",
|
||||
"type_motor": "Motoryacht",
|
||||
"length_m": "Länge (m)",
|
||||
"draft_m": "Tiefgang (m)",
|
||||
"air_draft_m": "Höhe (m)",
|
||||
"invalid_metric": "Ungültiger Zahlenwert — bitte Meter als Dezimalzahl eingeben (z. B. 12,5).",
|
||||
"port": "Heimathafen",
|
||||
"owner": "Eigner",
|
||||
"charter": "Charterfirma",
|
||||
@@ -101,6 +115,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)",
|
||||
@@ -128,6 +149,10 @@
|
||||
"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",
|
||||
@@ -147,8 +172,8 @@
|
||||
"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",
|
||||
"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",
|
||||
@@ -218,12 +243,22 @@
|
||||
"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.",
|
||||
"logged_in_as": "Angemeldet als {{name}}",
|
||||
"delete_confirm": "Sind Sie sicher, dass Sie dieses Logbuch unwiderruflich löschen möchten? Alle lokalen Daten und Server-Kopien werden vernichtet.\n\nTipp: Erstellen Sie vorher unter Einstellungen → Backup & Wiederherstellung eine Sicherungskopie (.daagbok.json), falls Sie die Daten später behalten möchten.",
|
||||
"no_logbooks": "Keine Logbücher gefunden. Erstellen Sie Ihr 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": "Sie wurden als Crew-Mitglied eingeladen. Skipper-Profil und Einstellungen gehören dem Eigner.",
|
||||
"role_owner": "Eigenes Logbuch",
|
||||
"role_owner_hint": "Sie sind Eigner und Skipper dieses Logbuchs",
|
||||
"role_crew": "Crew-Zugang",
|
||||
"role_crew_hint": "Eingeladenes Logbuch — Sie können als Crew mitarbeiten und signieren",
|
||||
"role_read": "Nur Lesen",
|
||||
"role_read_hint": "Geteiltes Logbuch — nur Ansicht, keine Bearbeitung"
|
||||
},
|
||||
"crew": {
|
||||
"title": "Skipper- & Crew-Profile",
|
||||
@@ -266,8 +301,8 @@
|
||||
"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.",
|
||||
"key_help": "Optional: eigener OpenWeatherMap-API-Schlüssel. Ohne Eintrag wird der serverseitige Schlüssel aus der Betreiber-Konfiguration verwendet.",
|
||||
"no_key": "Kein OpenWeatherMap-API-Schlüssel verfügbar. Hinterlegen Sie einen eigenen Schlüssel in den Einstellungen oder kontaktieren Sie den Betreiber.",
|
||||
"weather_success": "Wetterdaten erfolgreich abgerufen!",
|
||||
"weather_error": "Wetterdatenabruf fehlgeschlagen. Überprüfen Sie 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.",
|
||||
@@ -278,6 +313,11 @@
|
||||
"theme_ocean": "Ocean (Glassmorphismus)",
|
||||
"theme_material": "Material (Android)",
|
||||
"theme_cupertino": "Cupertino (iOS)",
|
||||
"color_scheme_title": "Erscheinungsbild",
|
||||
"color_scheme_label": "Hell- oder Dunkelmodus (Standard: Systemeinstellung)",
|
||||
"color_scheme_auto": "Automatisch (System)",
|
||||
"color_scheme_light": "Hell",
|
||||
"color_scheme_dark": "Dunkel",
|
||||
"share_title": "Logbuch teilen (Schreibgeschützt)",
|
||||
"share_desc": "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_enable": "Öffentlichen Link aktivieren",
|
||||
@@ -291,7 +331,177 @@
|
||||
"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…"
|
||||
"deleting_account": "Konto wird gelöscht…",
|
||||
"tour_title": "App-Tour",
|
||||
"tour_desc": "Lassen Sie sich erneut durch die wichtigsten Bereiche der App führen.",
|
||||
"tour_restart": "Tour erneut starten",
|
||||
"push_title": "Push-Benachrichtigungen",
|
||||
"push_desc": "Als Logbuch-Eigner werden Sie 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. Erlauben Sie 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.",
|
||||
"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. Bewahren Sie Datei und Passphrase getrennt und sicher auf.",
|
||||
"backup_restore_title": "Backup wiederherstellen",
|
||||
"backup_restore_desc": "Stellt ein Backup in Ihrem 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 melden Sie sich 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 lesen Sie die folgenden Hinweise, bevor Sie Kapteins Daagbok nutzen.",
|
||||
"e2e_title": "Ende-zu-Ende-Verschlüsselung",
|
||||
"e2e_body": "Ihre Logbuchdaten werden Ende-zu-Ende verschlüsselt. Nur Sie – bzw. Personen mit Ihrem 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 Ihrem Browser und kann auf Ihrem Gerät installiert werden – ähnlich wie eine native App, ohne App-Store.",
|
||||
"storage_title": "Lokale Speicherung & Synchronisation",
|
||||
"storage_body": "Ihre Daten werden lokal auf Ihrem Gerät zwischengespeichert (IndexedDB). Bei aktiver Internetverbindung werden Änderungen mit dem Server synchronisiert. Ohne Verbindung können Sie 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": "Teilen Sie Fehler, Ideen oder allgemeines Feedback. Ihre 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": "ihre@email.beispiel",
|
||||
"message_label": "Nachricht",
|
||||
"message_placeholder": "Beschreiben Sie Ihr Feedback…",
|
||||
"send": "Senden",
|
||||
"sending": "Wird gesendet…",
|
||||
"cancel": "Abbrechen",
|
||||
"success": "Vielen Dank! Ihr Feedback wurde gesendet.",
|
||||
"error_send": "Feedback konnte nicht gesendet werden. Bitte versuchen Sie es später erneut.",
|
||||
"error_invalid_email": "Bitte geben Sie eine gültige E-Mail-Adresse ein.",
|
||||
"error_not_configured": "Feedback ist auf diesem Server nicht verfügbar."
|
||||
},
|
||||
"demo": {
|
||||
"logbook_title": "Demo-Logbuch Ostsee",
|
||||
"badge": "Demo",
|
||||
"public_banner": "Schreibgeschützte Demo-Ansicht",
|
||||
"cta_register": "Account erstellen",
|
||||
"back_to_login": "Zur Anmeldung"
|
||||
},
|
||||
"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",
|
||||
"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_legend": "Kraftstoff",
|
||||
"water_legend": "Wasser",
|
||||
"unit_nm": "sm",
|
||||
"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 Sie angelegt. Diese kurze Tour zeigt Ihnen die wichtigsten Funktionen."
|
||||
},
|
||||
"welcome_public": {
|
||||
"title": "Willkommen an Bord!",
|
||||
"body": "Erkunden Sie unser Demo-Logbuch mit drei Reisetagen in der Kieler Förde – ganz ohne Account. Diese kurze Tour zeigt Ihnen Schiffsdaten, Crew und Logbucheinträge."
|
||||
},
|
||||
"nav_logs": {
|
||||
"title": "Logbucheinträge",
|
||||
"body": "Hier verwalten Sie Ihre Reisetage – Abfahrt, Ziel, Wetter, Tankstände und GPS-Tracks."
|
||||
},
|
||||
"entry_list": {
|
||||
"title": "Ihre Reisetage",
|
||||
"body": "Jede Karte steht für einen Reisetag. Tippen Sie 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": "Laden Sie GPX-Dateien hoch oder sehen Sie bereits gespeicherte Routen auf der Karte – inklusive Distanz und Geschwindigkeit."
|
||||
},
|
||||
"nav_vessel": {
|
||||
"title": "Schiffsdaten",
|
||||
"body": "Hinterlegen Sie Name, Maße und technische Daten Ihrer Yacht – einmal ausfüllen, für alle Reisetage verfügbar."
|
||||
},
|
||||
"nav_crew": {
|
||||
"title": "Crew-Liste",
|
||||
"body": "Verwalten Sie Besatzungsmitglieder und weisen Sie sie später Reisetagen zu."
|
||||
},
|
||||
"finish": {
|
||||
"title": "Alles klar!",
|
||||
"body": "Sie können die Tour jederzeit unter Einstellungen erneut starten. Gute Fahrt!"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"crew": "Crew List",
|
||||
"deviation": "Deviation Table",
|
||||
"logs": "Logbook Entries",
|
||||
"stats": "Statistics",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"auth": {
|
||||
@@ -37,6 +38,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",
|
||||
@@ -66,7 +68,11 @@
|
||||
"platform_ios": "Install via Safari",
|
||||
"platform_android": "Install via browser",
|
||||
"platform_desktop": "Install as desktop app",
|
||||
"settings_section": "App installation"
|
||||
"settings_section": "App installation",
|
||||
"update_title": "Update available",
|
||||
"update_desc": "A new version of Kapteins Daagbok is ready. Reload to get the latest changes.",
|
||||
"update_now": "Reload now",
|
||||
"update_reloading": "Reloading…"
|
||||
},
|
||||
"sync": {
|
||||
"status_synced": "Synced",
|
||||
@@ -76,6 +82,14 @@
|
||||
"vessel": {
|
||||
"title": "Vessel Master Data",
|
||||
"name": "Yacht Name",
|
||||
"type": "Vessel Type",
|
||||
"type_unset": "— not specified —",
|
||||
"type_sailing": "Sailing yacht",
|
||||
"type_motor": "Motor yacht",
|
||||
"length_m": "Length (m)",
|
||||
"draft_m": "Draft (m)",
|
||||
"air_draft_m": "Air draft (m)",
|
||||
"invalid_metric": "Invalid number — please enter meters as a decimal (e.g. 12.5).",
|
||||
"port": "Home Port",
|
||||
"owner": "Owner",
|
||||
"charter": "Charter Company",
|
||||
@@ -101,6 +115,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)",
|
||||
@@ -128,6 +149,10 @@
|
||||
"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",
|
||||
@@ -147,8 +172,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",
|
||||
@@ -218,12 +243,22 @@
|
||||
"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"
|
||||
},
|
||||
"crew": {
|
||||
"title": "Skipper & Crew Profiles",
|
||||
@@ -266,8 +301,8 @@
|
||||
"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.",
|
||||
"key_help": "Optional: your own OpenWeatherMap API key. If left empty, the operator-configured server key is used.",
|
||||
"no_key": "No OpenWeatherMap API key available. Add your own key in settings or contact the operator.",
|
||||
"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}}.",
|
||||
@@ -278,6 +313,11 @@
|
||||
"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_enable": "Enable Public Link",
|
||||
@@ -291,7 +331,177 @@
|
||||
"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…"
|
||||
"deleting_account": "Deleting account…",
|
||||
"tour_title": "App tour",
|
||||
"tour_desc": "Take a guided walkthrough of the main areas of the app again.",
|
||||
"tour_restart": "Restart tour",
|
||||
"push_title": "Push notifications",
|
||||
"push_desc": "As logbook owner you are notified when invited crew members sync changes. No logbook content is sent in plain text.",
|
||||
"push_enable": "Notify on crew changes",
|
||||
"push_active": "Push notifications are active on this device.",
|
||||
"push_unsupported": "Push notifications are not supported in this browser.",
|
||||
"push_denied_hint": "Notifications are blocked. Allow them in your browser or device settings.",
|
||||
"push_ios_install_hint": "On iPhone/iPad: add the app to your Home Screen (iOS 16.4+) to use push notifications.",
|
||||
"push_error": "Could not enable push notifications.",
|
||||
"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."
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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",
|
||||
"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_legend": "Fuel",
|
||||
"water_legend": "Water",
|
||||
"unit_nm": "nm",
|
||||
"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. 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."
|
||||
},
|
||||
"finish": {
|
||||
"title": "You're all set!",
|
||||
"body": "You can restart the tour anytime in Settings. Fair winds!"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { StrictMode } from 'react'
|
||||
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 { applyAppearanceToDocument } from './services/appearance.ts'
|
||||
|
||||
applyAppearanceToDocument()
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
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',
|
||||
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'
|
||||
} 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,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
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
export type ColorSchemePreference = 'auto' | 'light' | 'dark'
|
||||
export type ResolvedColorScheme = 'light' | 'dark'
|
||||
export type AppTheme = 'ocean' | 'material' | 'cupertino'
|
||||
|
||||
const THEME_CLASSES = ['theme-ocean', 'theme-material', 'theme-cupertino'] as const
|
||||
const SCHEME_CLASSES = ['scheme-light', 'scheme-dark'] as const
|
||||
|
||||
export function getColorSchemePreference(): ColorSchemePreference {
|
||||
const stored = localStorage.getItem('active_color_scheme')
|
||||
if (stored === 'light' || stored === 'dark' || stored === 'auto') return stored
|
||||
return 'auto'
|
||||
}
|
||||
|
||||
export function resolveColorScheme(pref?: ColorSchemePreference): ResolvedColorScheme {
|
||||
const preference = pref ?? getColorSchemePreference()
|
||||
if (preference === 'light') return 'light'
|
||||
if (preference === 'dark') return 'dark'
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
export function resolveAppTheme(): AppTheme {
|
||||
const configTheme = localStorage.getItem('active_theme') || 'auto'
|
||||
if (configTheme === 'material' || configTheme === 'cupertino' || configTheme === 'ocean') {
|
||||
return configTheme
|
||||
}
|
||||
const userAgent = navigator.userAgent || navigator.vendor || ''
|
||||
if (/iPad|iPhone|iPod|Macintosh/.test(userAgent)) return 'cupertino'
|
||||
if (/Android|Linux/.test(userAgent)) return 'material'
|
||||
return 'ocean'
|
||||
}
|
||||
|
||||
export function applyAppearanceToDocument(
|
||||
theme: AppTheme = resolveAppTheme(),
|
||||
scheme: ResolvedColorScheme = resolveColorScheme()
|
||||
): void {
|
||||
const root = document.documentElement
|
||||
root.classList.remove(...THEME_CLASSES, ...SCHEME_CLASSES)
|
||||
root.classList.add(`theme-${theme}`, `scheme-${scheme}`)
|
||||
root.style.colorScheme = scheme
|
||||
}
|
||||
|
||||
export function subscribeToSystemColorScheme(onChange: () => void): () => void {
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const handler = () => {
|
||||
if (getColorSchemePreference() === 'auto') onChange()
|
||||
}
|
||||
media.addEventListener('change', handler)
|
||||
return () => media.removeEventListener('change', handler)
|
||||
}
|
||||
|
||||
export function notifyAppearanceChanged(): void {
|
||||
window.dispatchEvent(new Event('appearance-changed'))
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
bufferToBase64
|
||||
} from './crypto.js'
|
||||
import { clearLogbookKeysCache } from './logbookKeys.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
|
||||
import { db } from './db.js'
|
||||
|
||||
const API_BASE = '/api/auth'
|
||||
@@ -254,6 +255,8 @@ export async function registerUser(username: string): Promise<RegistrationResult
|
||||
localStorage.setItem('active_username', username)
|
||||
localStorage.setItem('active_userid', result.userId)
|
||||
rememberUsername(username)
|
||||
sessionStorage.setItem('seed_demo_logbook', '1')
|
||||
trackPlausibleEvent(PlausibleEvents.ACCOUNT_CREATED)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -544,6 +547,7 @@ export async function deleteAccount(): Promise<boolean> {
|
||||
|
||||
// Wipe localStorage and session variables
|
||||
logoutUser()
|
||||
trackPlausibleEvent(PlausibleEvents.ACCOUNT_DELETED)
|
||||
return true
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -5,6 +5,9 @@ export interface LocalLogbook {
|
||||
encryptedTitle: string
|
||||
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 {
|
||||
@@ -120,6 +123,28 @@ class DaagboxDatabase extends Dexie {
|
||||
gpsTracks: 'entryId, logbookId, updatedAt',
|
||||
logbookKeys: 'logbookId'
|
||||
})
|
||||
this.version(4).stores({
|
||||
logbooks: 'id, encryptedTitle, updatedAt, isSynced, isShared',
|
||||
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'
|
||||
})
|
||||
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,318 @@
|
||||
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 }
|
||||
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 },
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
return {
|
||||
entryId,
|
||||
entryPayload,
|
||||
trackData: {
|
||||
waypoints,
|
||||
gpxContent: day.gpx,
|
||||
filename: day.filename,
|
||||
fileType: 'gpx'
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
export type FeedbackCategory = 'bug' | 'feature' | 'general'
|
||||
|
||||
export class FeedbackApiError extends Error {
|
||||
code: 'NOT_CONFIGURED' | 'REQUEST_FAILED' | 'INVALID_EMAIL'
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
code: 'NOT_CONFIGURED' | 'REQUEST_FAILED' | 'INVALID_EMAIL' = '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())
|
||||
}
|
||||
|
||||
function buildFeedbackHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
if (userId) headers['X-User-Id'] = userId
|
||||
return headers
|
||||
}
|
||||
|
||||
export async function sendFeedback(payload: {
|
||||
category: FeedbackCategory
|
||||
message: string
|
||||
contactEmail?: string | null
|
||||
logbookId?: string | null
|
||||
logbookTitle?: string | null
|
||||
}): Promise<void> {
|
||||
const contactEmail = payload.contactEmail?.trim()
|
||||
if (contactEmail && !isValidFeedbackEmail(contactEmail)) {
|
||||
throw new FeedbackApiError('Invalid email address', 'INVALID_EMAIL')
|
||||
}
|
||||
|
||||
const res = await fetch('/api/feedback', {
|
||||
method: 'POST',
|
||||
headers: buildFeedbackHeaders(),
|
||||
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
|
||||
})
|
||||
})
|
||||
|
||||
if (res.status === 503) {
|
||||
throw new FeedbackApiError('Feedback is not configured on this server', 'NOT_CONFIGURED')
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
throw new FeedbackApiError(data.error || 'Failed to send feedback')
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,36 @@ 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'
|
||||
|
||||
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
|
||||
@@ -43,8 +64,6 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
|
||||
throw new Error('Master key not found. User must log in.')
|
||||
}
|
||||
|
||||
const sharedLogbookIds = new Set<string>()
|
||||
|
||||
if (navigator.onLine) {
|
||||
try {
|
||||
const response = await fetch(API_BASE, {
|
||||
@@ -61,7 +80,6 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
|
||||
// Decrypt and save logbook keys locally if they exist
|
||||
for (const lb of serverLogbooks) {
|
||||
const isShared = lb.userId !== userId
|
||||
if (isShared) sharedLogbookIds.add(lb.id)
|
||||
|
||||
const encryptedKeyStr = isShared
|
||||
? lb.collaborators?.[0]?.encryptedLogbookKey
|
||||
@@ -101,12 +119,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
|
||||
}))
|
||||
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)
|
||||
@@ -128,7 +155,11 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
|
||||
title,
|
||||
updatedAt: lb.updatedAt,
|
||||
isSynced: lb.isSynced === 1,
|
||||
isShared: sharedLogbookIds.has(lb.id)
|
||||
isShared: lb.isShared === 1,
|
||||
accessRole: lb.isShared === 1
|
||||
? parseCollaborationRole(lb.collaborationRole, `cached logbook ${lb.id}`)
|
||||
: 'OWNER',
|
||||
isDemo: lb.isDemo === 1
|
||||
})
|
||||
}
|
||||
|
||||
@@ -195,7 +226,8 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
|
||||
id: serverLb.id,
|
||||
encryptedTitle: serverLb.encryptedTitle,
|
||||
updatedAt: serverLb.updatedAt,
|
||||
isSynced: 1
|
||||
isSynced: 1,
|
||||
isShared: 0
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -203,7 +235,8 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
|
||||
title,
|
||||
updatedAt: serverLb.updatedAt,
|
||||
isSynced: true,
|
||||
isShared: false
|
||||
isShared: false,
|
||||
accessRole: 'OWNER'
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -216,7 +249,8 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
|
||||
id: localId,
|
||||
encryptedTitle: encryptedTitleStr,
|
||||
updatedAt: now,
|
||||
isSynced: 0
|
||||
isSynced: 0,
|
||||
isShared: 0
|
||||
})
|
||||
|
||||
await db.syncQueue.put({
|
||||
@@ -233,7 +267,8 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
|
||||
title,
|
||||
updatedAt: now,
|
||||
isSynced: false,
|
||||
isShared: false
|
||||
isShared: false,
|
||||
accessRole: 'OWNER'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,4 +334,5 @@ export async function deleteLogbook(id: string): Promise<void> {
|
||||
|
||||
// Perform local cascading cleanup
|
||||
await deleteLocalLogbookCache(id)
|
||||
trackPlausibleEvent(PlausibleEvents.LOGBOOK_DELETED)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
const API_BASE = '/api/push'
|
||||
|
||||
function getUserId(): string | null {
|
||||
return localStorage.getItem('active_userid')
|
||||
}
|
||||
|
||||
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 }> {
|
||||
const userId = getUserId()
|
||||
if (!userId) return { collaboratorChangesEnabled: false }
|
||||
|
||||
const res = await fetch(`${API_BASE}/prefs`, {
|
||||
headers: { 'X-User-Id': userId }
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to load push notification preferences')
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function savePushPrefs(collaboratorChangesEnabled: boolean): Promise<void> {
|
||||
const userId = getUserId()
|
||||
if (!userId) throw new Error('Not authenticated')
|
||||
|
||||
const res = await fetch(`${API_BASE}/prefs`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-User-Id': userId
|
||||
},
|
||||
body: JSON.stringify({ collaboratorChangesEnabled })
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to save push notification preferences')
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSubscriptionToServer(subscription: PushSubscription): Promise<void> {
|
||||
const userId = getUserId()
|
||||
if (!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'
|
||||
|
||||
const res = await fetch(`${API_BASE}/subscription`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-User-Id': userId
|
||||
},
|
||||
body: JSON.stringify({
|
||||
endpoint: json.endpoint,
|
||||
keys: json.keys,
|
||||
locale,
|
||||
userAgent: navigator.userAgent
|
||||
})
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to register push subscription on server')
|
||||
}
|
||||
}
|
||||
|
||||
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 userId = getUserId()
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
const subscription = await registration.pushManager.getSubscription()
|
||||
if (!subscription) return
|
||||
|
||||
const endpoint = subscription.endpoint
|
||||
await subscription.unsubscribe()
|
||||
|
||||
if (userId && endpoint) {
|
||||
await fetch(`${API_BASE}/subscription`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-User-Id': userId
|
||||
},
|
||||
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,251 @@
|
||||
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'
|
||||
|
||||
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
|
||||
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
|
||||
totalFuelL: number
|
||||
totalFreshwaterL: number
|
||||
avgDistancePerDayNm: number
|
||||
avgFuelPerDayL: number
|
||||
avgFreshwaterPerDayL: number
|
||||
fuelPerNmL: 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 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)),
|
||||
totalFuelL: Number(totalFuelL.toFixed(1)),
|
||||
totalFreshwaterL: Number(totalFreshwaterL.toFixed(1)),
|
||||
avgDistancePerDayNm:
|
||||
travelDayCount > 0 ? Number((totalDistanceNm / travelDayCount).toFixed(2)) : 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
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
days.push({
|
||||
entryId: entry.payloadId,
|
||||
logbookId,
|
||||
date: payload.date || '',
|
||||
dayOfTravel: payload.dayOfTravel || '',
|
||||
departure: payload.departure || '',
|
||||
destination: payload.destination || '',
|
||||
distanceNm,
|
||||
distanceSource,
|
||||
fuelConsumptionL: Number(payload.fuel?.consumption) || 0,
|
||||
freshwaterConsumptionL: Number(payload.freshwater?.consumption) || 0,
|
||||
sailDistanceNm: propulsion.sailDistanceNm,
|
||||
motorDistanceNm: propulsion.motorDistanceNm,
|
||||
unknownPropulsionNm: propulsion.unknownPropulsionNm,
|
||||
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)
|
||||
}
|
||||
+144
-13
@@ -1,8 +1,9 @@
|
||||
import { db } from './db.js'
|
||||
import { db, type SyncQueueItem } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.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,13 +28,108 @@ 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))
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
// 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
|
||||
|
||||
try {
|
||||
@@ -53,13 +149,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,6 +170,21 @@ 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')
|
||||
@@ -266,14 +378,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 +414,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 +435,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,52 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
function buildWeatherHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {}
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
const userKey = localStorage.getItem('owm_api_key')?.trim()
|
||||
|
||||
if (userId) headers['X-User-Id'] = userId
|
||||
if (userKey) headers['X-OWM-Api-Key'] = userKey
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
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 res = await fetch(`/api/weather/current?${searchParams.toString()}`, {
|
||||
headers: buildWeatherHeaders()
|
||||
})
|
||||
|
||||
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)
|
||||
})()
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,398 @@
|
||||
/**
|
||||
* Appearance tokens: scheme (light/dark) × theme (ocean/material/cupertino)
|
||||
* Applied on document.documentElement via appearance.ts
|
||||
*/
|
||||
|
||||
/* Fallback before JS hydrates (ocean · dark) */
|
||||
html {
|
||||
color-scheme: dark;
|
||||
--app-body-bg: radial-gradient(circle at center, #1b264f 0%, #0b0c10 100%);
|
||||
--app-text: #f1f5f9;
|
||||
--app-text-heading: #f8fafc;
|
||||
--app-text-muted: #94a3b8;
|
||||
--app-text-subtle: #64748b;
|
||||
--app-surface: rgba(11, 12, 16, 0.75);
|
||||
--app-surface-alt: rgba(11, 12, 16, 0.6);
|
||||
--app-surface-hover: rgba(11, 12, 16, 0.85);
|
||||
--app-surface-inset: rgba(255, 255, 255, 0.02);
|
||||
--app-border: rgba(212, 175, 55, 0.25);
|
||||
--app-border-subtle: rgba(255, 255, 255, 0.08);
|
||||
--app-border-muted: rgba(212, 175, 55, 0.15);
|
||||
--app-input-bg: rgba(11, 12, 16, 0.85);
|
||||
--app-input-bg-focus: #0b0c10;
|
||||
--app-input-border: rgba(148, 163, 184, 0.25);
|
||||
--app-input-text: #f1f5f9;
|
||||
--app-accent: #d97706;
|
||||
--app-accent-light: #fbbf24;
|
||||
--app-accent-gradient: linear-gradient(135deg, #fef08a 0%, #d97706 100%);
|
||||
--app-accent-bg: rgba(217, 119, 6, 0.1);
|
||||
--app-accent-border: rgba(217, 119, 6, 0.2);
|
||||
--app-accent-focus-ring: rgba(217, 119, 6, 0.2);
|
||||
--app-btn-primary-text: #0b0c10;
|
||||
--app-btn-secondary-bg: rgba(255, 255, 255, 0.05);
|
||||
--app-btn-secondary-border: rgba(255, 255, 255, 0.12);
|
||||
--app-btn-secondary-text: #e2e8f0;
|
||||
--app-btn-secondary-hover-bg: rgba(255, 255, 255, 0.08);
|
||||
--app-icon-btn-bg: rgba(255, 255, 255, 0.05);
|
||||
--app-icon-btn-border: rgba(255, 255, 255, 0.1);
|
||||
--app-divider: rgba(255, 255, 255, 0.06);
|
||||
--app-shadow: 0 20px 50px rgba(0, 0, 0, 0.6), inset 0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
--app-card-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
|
||||
--app-error-bg: rgba(244, 63, 94, 0.08);
|
||||
--app-error-text: #fda4af;
|
||||
--app-error-border: #f43f5e;
|
||||
--app-warning-text: #f43f5e;
|
||||
--app-warning-bg: rgba(244, 63, 94, 0.08);
|
||||
--app-warning-border: rgba(244, 63, 94, 0.2);
|
||||
--app-empty-border: rgba(255, 255, 255, 0.08);
|
||||
--app-empty-bg: rgba(255, 255, 255, 0.02);
|
||||
--app-sidebar-active-bg: rgba(217, 119, 6, 0.12);
|
||||
--app-sidebar-active-border: #d97706;
|
||||
--app-sidebar-active-text: #fbbf24;
|
||||
--app-header-border: rgba(212, 175, 55, 0.15);
|
||||
--app-table-border: rgba(255, 255, 255, 0.08);
|
||||
--app-progress-bar: linear-gradient(90deg, #d97706, #fbbf24, #d97706);
|
||||
--app-backdrop: blur(20px);
|
||||
--app-radius-card: 16px;
|
||||
--app-radius-input: 10px;
|
||||
--app-radius-btn: 10px;
|
||||
}
|
||||
|
||||
/* ===== OCEAN · DARK (default) ===== */
|
||||
html.scheme-dark.theme-ocean {
|
||||
color-scheme: dark;
|
||||
--app-body-bg: radial-gradient(circle at center, #1b264f 0%, #0b0c10 100%);
|
||||
--app-text: #f1f5f9;
|
||||
--app-text-heading: #f8fafc;
|
||||
--app-text-muted: #94a3b8;
|
||||
--app-text-subtle: #64748b;
|
||||
--app-surface: rgba(11, 12, 16, 0.75);
|
||||
--app-surface-alt: rgba(11, 12, 16, 0.6);
|
||||
--app-surface-hover: rgba(11, 12, 16, 0.85);
|
||||
--app-surface-inset: rgba(255, 255, 255, 0.02);
|
||||
--app-border: rgba(212, 175, 55, 0.25);
|
||||
--app-border-subtle: rgba(255, 255, 255, 0.08);
|
||||
--app-border-muted: rgba(212, 175, 55, 0.15);
|
||||
--app-input-bg: rgba(11, 12, 16, 0.85);
|
||||
--app-input-bg-focus: #0b0c10;
|
||||
--app-input-border: rgba(148, 163, 184, 0.25);
|
||||
--app-input-text: #f1f5f9;
|
||||
--app-accent: #d97706;
|
||||
--app-accent-light: #fbbf24;
|
||||
--app-accent-gradient: linear-gradient(135deg, #fef08a 0%, #d97706 100%);
|
||||
--app-accent-bg: rgba(217, 119, 6, 0.1);
|
||||
--app-accent-border: rgba(217, 119, 6, 0.2);
|
||||
--app-accent-focus-ring: rgba(217, 119, 6, 0.2);
|
||||
--app-btn-primary-text: #0b0c10;
|
||||
--app-btn-secondary-bg: rgba(255, 255, 255, 0.05);
|
||||
--app-btn-secondary-border: rgba(255, 255, 255, 0.12);
|
||||
--app-btn-secondary-text: #e2e8f0;
|
||||
--app-btn-secondary-hover-bg: rgba(255, 255, 255, 0.08);
|
||||
--app-icon-btn-bg: rgba(255, 255, 255, 0.05);
|
||||
--app-icon-btn-border: rgba(255, 255, 255, 0.1);
|
||||
--app-divider: rgba(255, 255, 255, 0.06);
|
||||
--app-shadow: 0 20px 50px rgba(0, 0, 0, 0.6), inset 0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
--app-card-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
|
||||
--app-error-bg: rgba(244, 63, 94, 0.08);
|
||||
--app-error-text: #fda4af;
|
||||
--app-error-border: #f43f5e;
|
||||
--app-warning-text: #f43f5e;
|
||||
--app-warning-bg: rgba(244, 63, 94, 0.08);
|
||||
--app-warning-border: rgba(244, 63, 94, 0.2);
|
||||
--app-empty-border: rgba(255, 255, 255, 0.08);
|
||||
--app-empty-bg: rgba(255, 255, 255, 0.02);
|
||||
--app-sidebar-active-bg: rgba(217, 119, 6, 0.12);
|
||||
--app-sidebar-active-border: #d97706;
|
||||
--app-sidebar-active-text: #fbbf24;
|
||||
--app-header-border: rgba(212, 175, 55, 0.15);
|
||||
--app-table-border: rgba(255, 255, 255, 0.08);
|
||||
--app-progress-bar: linear-gradient(90deg, #d97706, #fbbf24, #d97706);
|
||||
--app-backdrop: blur(20px);
|
||||
--app-radius-card: 16px;
|
||||
--app-radius-input: 10px;
|
||||
--app-radius-btn: 10px;
|
||||
}
|
||||
|
||||
/* ===== OCEAN · LIGHT ===== */
|
||||
html.scheme-light.theme-ocean {
|
||||
color-scheme: light;
|
||||
--app-body-bg: linear-gradient(165deg, #dbeafe 0%, #f8fafc 42%, #e2e8f0 100%);
|
||||
--app-text: #1e293b;
|
||||
--app-text-heading: #0f172a;
|
||||
--app-text-muted: #475569;
|
||||
--app-text-subtle: #64748b;
|
||||
--app-surface: rgba(255, 255, 255, 0.88);
|
||||
--app-surface-alt: rgba(255, 255, 255, 0.78);
|
||||
--app-surface-hover: rgba(255, 255, 255, 0.96);
|
||||
--app-surface-inset: rgba(15, 23, 42, 0.03);
|
||||
--app-border: rgba(217, 119, 6, 0.28);
|
||||
--app-border-subtle: rgba(15, 23, 42, 0.1);
|
||||
--app-border-muted: rgba(217, 119, 6, 0.18);
|
||||
--app-input-bg: #ffffff;
|
||||
--app-input-bg-focus: #ffffff;
|
||||
--app-input-border: rgba(100, 116, 139, 0.35);
|
||||
--app-input-text: #0f172a;
|
||||
--app-accent: #b45309;
|
||||
--app-accent-light: #d97706;
|
||||
--app-accent-gradient: linear-gradient(135deg, #fcd34d 0%, #b45309 100%);
|
||||
--app-accent-bg: rgba(217, 119, 6, 0.12);
|
||||
--app-accent-border: rgba(217, 119, 6, 0.25);
|
||||
--app-accent-focus-ring: rgba(217, 119, 6, 0.25);
|
||||
--app-btn-primary-text: #0b0c10;
|
||||
--app-btn-secondary-bg: rgba(15, 23, 42, 0.04);
|
||||
--app-btn-secondary-border: rgba(15, 23, 42, 0.12);
|
||||
--app-btn-secondary-text: #334155;
|
||||
--app-btn-secondary-hover-bg: rgba(15, 23, 42, 0.07);
|
||||
--app-icon-btn-bg: rgba(15, 23, 42, 0.04);
|
||||
--app-icon-btn-border: rgba(15, 23, 42, 0.1);
|
||||
--app-divider: rgba(15, 23, 42, 0.08);
|
||||
--app-shadow: 0 16px 40px rgba(15, 23, 42, 0.12), inset 0 0 0 1px rgba(255, 255, 255, 0.6);
|
||||
--app-card-shadow: 0 8px 24px rgba(15, 23, 42, 0.1);
|
||||
--app-error-bg: rgba(244, 63, 94, 0.08);
|
||||
--app-error-text: #be123c;
|
||||
--app-error-border: #e11d48;
|
||||
--app-warning-text: #be123c;
|
||||
--app-warning-bg: rgba(244, 63, 94, 0.06);
|
||||
--app-warning-border: rgba(244, 63, 94, 0.2);
|
||||
--app-empty-border: rgba(15, 23, 42, 0.12);
|
||||
--app-empty-bg: rgba(15, 23, 42, 0.02);
|
||||
--app-sidebar-active-bg: rgba(217, 119, 6, 0.1);
|
||||
--app-sidebar-active-border: #d97706;
|
||||
--app-sidebar-active-text: #b45309;
|
||||
--app-header-border: rgba(217, 119, 6, 0.2);
|
||||
--app-table-border: rgba(15, 23, 42, 0.1);
|
||||
--app-progress-bar: linear-gradient(90deg, #d97706, #fbbf24, #d97706);
|
||||
--app-backdrop: blur(20px);
|
||||
--app-radius-card: 16px;
|
||||
--app-radius-input: 10px;
|
||||
--app-radius-btn: 10px;
|
||||
}
|
||||
|
||||
/* ===== MATERIAL · DARK ===== */
|
||||
html.scheme-dark.theme-material {
|
||||
color-scheme: dark;
|
||||
--app-body-bg: #121212;
|
||||
--app-text: #f1f5f9;
|
||||
--app-text-heading: #f8fafc;
|
||||
--app-text-muted: #94a3b8;
|
||||
--app-text-subtle: #64748b;
|
||||
--app-surface: #1e1e1e;
|
||||
--app-surface-alt: #1e1e1e;
|
||||
--app-surface-hover: #252525;
|
||||
--app-surface-inset: #2a2a2a;
|
||||
--app-border: #2d2d2d;
|
||||
--app-border-subtle: #2d2d2d;
|
||||
--app-border-muted: #2d2d2d;
|
||||
--app-input-bg: #2a2a2a;
|
||||
--app-input-bg-focus: #2a2a2a;
|
||||
--app-input-border: #3d3d3d;
|
||||
--app-input-text: #f1f5f9;
|
||||
--app-accent: #00adb5;
|
||||
--app-accent-light: #00adb5;
|
||||
--app-accent-gradient: linear-gradient(135deg, #00adb5 0%, #008f95 100%);
|
||||
--app-accent-bg: rgba(0, 173, 181, 0.12);
|
||||
--app-accent-border: rgba(0, 173, 181, 0.3);
|
||||
--app-accent-focus-ring: rgba(0, 173, 181, 0.2);
|
||||
--app-btn-primary-text: #ffffff;
|
||||
--app-btn-secondary-bg: #2a2a2a;
|
||||
--app-btn-secondary-border: #3d3d3d;
|
||||
--app-btn-secondary-text: #f1f5f9;
|
||||
--app-btn-secondary-hover-bg: #333333;
|
||||
--app-icon-btn-bg: #2a2a2a;
|
||||
--app-icon-btn-border: #3d3d3d;
|
||||
--app-divider: #2d2d2d;
|
||||
--app-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
--app-card-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||
--app-error-bg: rgba(244, 63, 94, 0.08);
|
||||
--app-error-text: #fda4af;
|
||||
--app-error-border: #f43f5e;
|
||||
--app-warning-text: #f43f5e;
|
||||
--app-warning-bg: rgba(244, 63, 94, 0.08);
|
||||
--app-warning-border: rgba(244, 63, 94, 0.2);
|
||||
--app-empty-border: #2d2d2d;
|
||||
--app-empty-bg: #1a1a1a;
|
||||
--app-sidebar-active-bg: rgba(0, 173, 181, 0.08);
|
||||
--app-sidebar-active-border: #00adb5;
|
||||
--app-sidebar-active-text: #00adb5;
|
||||
--app-header-border: #2d2d2d;
|
||||
--app-table-border: #2d2d2d;
|
||||
--app-progress-bar: linear-gradient(90deg, #00adb5, #008f95, #00adb5);
|
||||
--app-backdrop: none;
|
||||
--app-radius-card: 4px;
|
||||
--app-radius-input: 4px;
|
||||
--app-radius-btn: 4px;
|
||||
}
|
||||
|
||||
/* ===== MATERIAL · LIGHT ===== */
|
||||
html.scheme-light.theme-material {
|
||||
color-scheme: light;
|
||||
--app-body-bg: #fafafa;
|
||||
--app-text: #212121;
|
||||
--app-text-heading: #111827;
|
||||
--app-text-muted: #616161;
|
||||
--app-text-subtle: #757575;
|
||||
--app-surface: #ffffff;
|
||||
--app-surface-alt: #ffffff;
|
||||
--app-surface-hover: #f5f5f5;
|
||||
--app-surface-inset: #f5f5f5;
|
||||
--app-border: #e0e0e0;
|
||||
--app-border-subtle: #eeeeee;
|
||||
--app-border-muted: #e0e0e0;
|
||||
--app-input-bg: #ffffff;
|
||||
--app-input-bg-focus: #ffffff;
|
||||
--app-input-border: #bdbdbd;
|
||||
--app-input-text: #212121;
|
||||
--app-accent: #00838f;
|
||||
--app-accent-light: #00838f;
|
||||
--app-accent-gradient: linear-gradient(135deg, #00838f 0%, #006064 100%);
|
||||
--app-accent-bg: rgba(0, 131, 143, 0.1);
|
||||
--app-accent-border: rgba(0, 131, 143, 0.25);
|
||||
--app-accent-focus-ring: rgba(0, 131, 143, 0.2);
|
||||
--app-btn-primary-text: #ffffff;
|
||||
--app-btn-secondary-bg: #f5f5f5;
|
||||
--app-btn-secondary-border: #e0e0e0;
|
||||
--app-btn-secondary-text: #424242;
|
||||
--app-btn-secondary-hover-bg: #eeeeee;
|
||||
--app-icon-btn-bg: #f5f5f5;
|
||||
--app-icon-btn-border: #e0e0e0;
|
||||
--app-divider: #e0e0e0;
|
||||
--app-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
|
||||
--app-card-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
--app-error-bg: rgba(244, 63, 94, 0.08);
|
||||
--app-error-text: #be123c;
|
||||
--app-error-border: #e11d48;
|
||||
--app-warning-text: #be123c;
|
||||
--app-warning-bg: rgba(244, 63, 94, 0.06);
|
||||
--app-warning-border: rgba(244, 63, 94, 0.2);
|
||||
--app-empty-border: #e0e0e0;
|
||||
--app-empty-bg: #fafafa;
|
||||
--app-sidebar-active-bg: rgba(0, 131, 143, 0.08);
|
||||
--app-sidebar-active-border: #00838f;
|
||||
--app-sidebar-active-text: #00838f;
|
||||
--app-header-border: #e0e0e0;
|
||||
--app-table-border: #e0e0e0;
|
||||
--app-progress-bar: linear-gradient(90deg, #00838f, #00adb5, #00838f);
|
||||
--app-backdrop: none;
|
||||
--app-radius-card: 4px;
|
||||
--app-radius-input: 4px;
|
||||
--app-radius-btn: 4px;
|
||||
}
|
||||
|
||||
/* ===== CUPERTINO · DARK ===== */
|
||||
html.scheme-dark.theme-cupertino {
|
||||
color-scheme: dark;
|
||||
--app-body-bg: #000000;
|
||||
--app-text: #ffffff;
|
||||
--app-text-heading: #ffffff;
|
||||
--app-text-muted: #aeaeb2;
|
||||
--app-text-subtle: #8e8e93;
|
||||
--app-surface: rgba(28, 28, 30, 0.72);
|
||||
--app-surface-alt: rgba(28, 28, 30, 0.72);
|
||||
--app-surface-hover: rgba(44, 44, 46, 0.85);
|
||||
--app-surface-inset: rgba(255, 255, 255, 0.05);
|
||||
--app-border: rgba(255, 255, 255, 0.1);
|
||||
--app-border-subtle: rgba(255, 255, 255, 0.1);
|
||||
--app-border-muted: rgba(255, 255, 255, 0.08);
|
||||
--app-input-bg: rgba(255, 255, 255, 0.05);
|
||||
--app-input-bg-focus: rgba(255, 255, 255, 0.07);
|
||||
--app-input-border: rgba(255, 255, 255, 0.12);
|
||||
--app-input-text: #ffffff;
|
||||
--app-accent: #0a84ff;
|
||||
--app-accent-light: #0a84ff;
|
||||
--app-accent-gradient: linear-gradient(135deg, #0a84ff 0%, #007aff 100%);
|
||||
--app-accent-bg: rgba(10, 132, 255, 0.12);
|
||||
--app-accent-border: rgba(10, 132, 255, 0.3);
|
||||
--app-accent-focus-ring: rgba(10, 132, 255, 0.25);
|
||||
--app-btn-primary-text: #ffffff;
|
||||
--app-btn-secondary-bg: rgba(255, 255, 255, 0.08);
|
||||
--app-btn-secondary-border: rgba(255, 255, 255, 0.12);
|
||||
--app-btn-secondary-text: #ffffff;
|
||||
--app-btn-secondary-hover-bg: rgba(255, 255, 255, 0.12);
|
||||
--app-icon-btn-bg: rgba(255, 255, 255, 0.08);
|
||||
--app-icon-btn-border: rgba(255, 255, 255, 0.12);
|
||||
--app-divider: rgba(255, 255, 255, 0.08);
|
||||
--app-shadow: none;
|
||||
--app-card-shadow: none;
|
||||
--app-error-bg: rgba(255, 69, 58, 0.12);
|
||||
--app-error-text: #ff6961;
|
||||
--app-error-border: #ff453a;
|
||||
--app-warning-text: #ff6961;
|
||||
--app-warning-bg: rgba(255, 69, 58, 0.12);
|
||||
--app-warning-border: rgba(255, 69, 58, 0.25);
|
||||
--app-empty-border: rgba(255, 255, 255, 0.1);
|
||||
--app-empty-bg: rgba(255, 255, 255, 0.04);
|
||||
--app-sidebar-active-bg: rgba(10, 132, 255, 0.15);
|
||||
--app-sidebar-active-border: #0a84ff;
|
||||
--app-sidebar-active-text: #0a84ff;
|
||||
--app-header-border: rgba(255, 255, 255, 0.1);
|
||||
--app-table-border: rgba(255, 255, 255, 0.1);
|
||||
--app-progress-bar: linear-gradient(90deg, #0a84ff, #007aff, #0a84ff);
|
||||
--app-backdrop: blur(25px);
|
||||
--app-radius-card: 12px;
|
||||
--app-radius-input: 8px;
|
||||
--app-radius-btn: 9999px;
|
||||
}
|
||||
|
||||
/* ===== CUPERTINO · LIGHT ===== */
|
||||
html.scheme-light.theme-cupertino {
|
||||
color-scheme: light;
|
||||
--app-body-bg: #f2f2f7;
|
||||
--app-text: #1c1c1e;
|
||||
--app-text-heading: #000000;
|
||||
--app-text-muted: #636366;
|
||||
--app-text-subtle: #8e8e93;
|
||||
--app-surface: rgba(255, 255, 255, 0.82);
|
||||
--app-surface-alt: rgba(255, 255, 255, 0.82);
|
||||
--app-surface-hover: rgba(255, 255, 255, 0.95);
|
||||
--app-surface-inset: rgba(0, 0, 0, 0.03);
|
||||
--app-border: rgba(0, 0, 0, 0.08);
|
||||
--app-border-subtle: rgba(0, 0, 0, 0.06);
|
||||
--app-border-muted: rgba(0, 0, 0, 0.08);
|
||||
--app-input-bg: #ffffff;
|
||||
--app-input-bg-focus: #ffffff;
|
||||
--app-input-border: rgba(0, 0, 0, 0.12);
|
||||
--app-input-text: #1c1c1e;
|
||||
--app-accent: #007aff;
|
||||
--app-accent-light: #007aff;
|
||||
--app-accent-gradient: linear-gradient(135deg, #007aff 0%, #0a84ff 100%);
|
||||
--app-accent-bg: rgba(0, 122, 255, 0.1);
|
||||
--app-accent-border: rgba(0, 122, 255, 0.25);
|
||||
--app-accent-focus-ring: rgba(0, 122, 255, 0.2);
|
||||
--app-btn-primary-text: #ffffff;
|
||||
--app-btn-secondary-bg: rgba(0, 0, 0, 0.05);
|
||||
--app-btn-secondary-border: rgba(0, 0, 0, 0.08);
|
||||
--app-btn-secondary-text: #1c1c1e;
|
||||
--app-btn-secondary-hover-bg: rgba(0, 0, 0, 0.08);
|
||||
--app-icon-btn-bg: rgba(0, 0, 0, 0.05);
|
||||
--app-icon-btn-border: rgba(0, 0, 0, 0.08);
|
||||
--app-divider: rgba(0, 0, 0, 0.08);
|
||||
--app-shadow: none;
|
||||
--app-card-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||
--app-error-bg: rgba(255, 59, 48, 0.1);
|
||||
--app-error-text: #d70015;
|
||||
--app-error-border: #ff3b30;
|
||||
--app-warning-text: #d70015;
|
||||
--app-warning-bg: rgba(255, 59, 48, 0.08);
|
||||
--app-warning-border: rgba(255, 59, 48, 0.2);
|
||||
--app-empty-border: rgba(0, 0, 0, 0.08);
|
||||
--app-empty-bg: rgba(0, 0, 0, 0.02);
|
||||
--app-sidebar-active-bg: rgba(0, 122, 255, 0.1);
|
||||
--app-sidebar-active-border: #007aff;
|
||||
--app-sidebar-active-text: #007aff;
|
||||
--app-header-border: rgba(0, 0, 0, 0.08);
|
||||
--app-table-border: rgba(0, 0, 0, 0.08);
|
||||
--app-progress-bar: linear-gradient(90deg, #007aff, #0a84ff, #007aff);
|
||||
--app-backdrop: blur(25px);
|
||||
--app-radius-card: 12px;
|
||||
--app-radius-input: 8px;
|
||||
--app-radius-btn: 9999px;
|
||||
}
|
||||
|
||||
/* Utility classes for inline-style migration */
|
||||
.text-muted { color: var(--app-text-muted); }
|
||||
.text-subtle { color: var(--app-text-subtle); }
|
||||
.text-heading { color: var(--app-text-heading); }
|
||||
|
||||
html.scheme-light #root {
|
||||
border-inline-color: var(--app-border-subtle);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { hashEntryForSigning } from './entryCanonicalHash.js'
|
||||
import type { PasskeySignature, SignatureValue } from '../types/signatures.js'
|
||||
|
||||
export type SkipperSignStatus = 'none' | 'valid' | 'invalid'
|
||||
|
||||
export function isSignatureImage(value: string | undefined | null): boolean {
|
||||
return typeof value === 'string' && value.startsWith('data:image/')
|
||||
}
|
||||
@@ -31,6 +34,16 @@ 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
|
||||
@@ -55,3 +68,12 @@ export function serializeSignature(value: SignatureValue | '' | undefined): Sign
|
||||
const trimmed = value.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
+24
-1
@@ -1,3 +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 {}
|
||||
|
||||
@@ -38,12 +38,18 @@ export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
strategies: 'injectManifest',
|
||||
srcDir: 'src',
|
||||
filename: 'sw.ts',
|
||||
registerType: 'prompt',
|
||||
includeAssets: ['favicon.ico', 'logo.png'],
|
||||
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',
|
||||
description: 'Free, ad-free maritime logbook with E2E encryption and Passkeys',
|
||||
theme_color: '#1e293b',
|
||||
background_color: '#0f172a',
|
||||
display: 'standalone',
|
||||
|
||||
@@ -26,6 +26,13 @@ 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:-}
|
||||
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 |
@@ -0,0 +1,290 @@
|
||||
<!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;
|
||||
padding: 14mm 16mm 12mm;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
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;
|
||||
margin-bottom: 6mm;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 14mm;
|
||||
height: 14mm;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title-block h1 {
|
||||
font-size: 22pt;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: #f8fafc;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.title-block p {
|
||||
font-size: 10.5pt;
|
||||
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: 9pt;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.12em;
|
||||
padding: 2mm 4mm;
|
||||
border-radius: 2mm;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.intro {
|
||||
font-size: 10.5pt;
|
||||
line-height: 1.55;
|
||||
color: #cbd5e1;
|
||||
margin-bottom: 6mm;
|
||||
max-width: 95%;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.intro strong {
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.features {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 3mm 6mm;
|
||||
margin-bottom: 6mm;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.feature {
|
||||
display: flex;
|
||||
gap: 2.5mm;
|
||||
align-items: flex-start;
|
||||
font-size: 9.5pt;
|
||||
line-height: 1.4;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
color: #38bdf8;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
width: 4mm;
|
||||
}
|
||||
|
||||
.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;
|
||||
margin-bottom: 6mm;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.beta-box h2 {
|
||||
font-size: 11pt;
|
||||
color: #fbbf24;
|
||||
margin-bottom: 2mm;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.beta-box p {
|
||||
font-size: 9.5pt;
|
||||
line-height: 1.5;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.cta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8mm;
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
border-radius: 4mm;
|
||||
padding: 5mm 6mm;
|
||||
margin-bottom: auto;
|
||||
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: 13pt;
|
||||
color: #38bdf8;
|
||||
font-weight: 700;
|
||||
margin-bottom: 2mm;
|
||||
}
|
||||
|
||||
.cta-text p {
|
||||
font-size: 9pt;
|
||||
color: #94a3b8;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2mm;
|
||||
margin-top: 3mm;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 7.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: 5mm;
|
||||
font-size: 7.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/favicon.svg" alt="" />
|
||||
<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ühren Sie Ihr Bordlogbuch digital: Reisetage, GPS-Tracks, Crew und Schiffsdaten —
|
||||
<strong>End-to-End-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, Tankstände)</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Offline-fähige PWA — installierbar auf Smartphone & Tablet</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Passkey-Anmeldung & clientseitige Verschlüsselung</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>GPS-Tracks (GPX/KML), Karte & 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>Crew einladen — gemeinsam am Logbuch arbeiten</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>PDF- & CSV-Export, verschlüsseltes Backup</span></div>
|
||||
<div class="feature"><span class="feature-icon">✦</span><span>Mehrere Logbücher · Deutsch & Englisch</span></div>
|
||||
</section>
|
||||
|
||||
<section class="beta-box">
|
||||
<h2>Beta-Phase — Ihr Feedback zählt</h2>
|
||||
<p>
|
||||
Kapteins Daagbok ist ein <strong>privates Hobbyprojekt ohne Gewinnabsicht</strong>.
|
||||
Als Beta-Tester helfen Sie, 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>
|
||||
Binary file not shown.
@@ -0,0 +1,67 @@
|
||||
# Plausible Custom Events
|
||||
|
||||
Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Script `script.tagged-events.js` auf der Domain `kapteins-daagbok.eu`. Custom Events werden über `window.plausible()` ausgelöst (siehe `client/src/services/analytics.ts`).
|
||||
|
||||
**Datenschutz:** Es werden keine personenbezogenen Daten in Event-Properties übermittelt (keine Nutzernamen, Hafennamen, Koordinaten o.ä.).
|
||||
|
||||
## Setup
|
||||
|
||||
1. Script in `client/index.html` (bereits eingebunden)
|
||||
2. Nach Deploy: Goals im Plausible-Dashboard anlegen — **Namen müssen exakt mit der Event-Spalte „Event name“ übereinstimmen** (Title Case, Leerzeichen)
|
||||
|
||||
## Event-Übersicht
|
||||
|
||||
| Event | Auslöser | Properties |
|
||||
|-------|----------|------------|
|
||||
| Account Created | Erfolgreiche Registrierung (`auth.ts`) | — |
|
||||
| Logged In | Login oder Einladungs-Flow abgeschlossen (`App.tsx`) | — |
|
||||
| Logbook Created | Neues Logbuch im Dashboard (`LogbookDashboard.tsx`) | — |
|
||||
| Logbook Deleted | Logbuch gelöscht (`logbook.ts`) | — |
|
||||
| Travel Day Created | Neuer Reisetag über „+“ in der Eintragsliste (`LogEntriesList.tsx`) | — |
|
||||
| Travel Day Saved | Reisetag gespeichert (`LogEntryEditor.tsx`) | — |
|
||||
| Entry Signed | Passkey-Signatur Skipper oder Crew (`LogEntryEditor.tsx`) | `role`: `skipper` \| `crew` |
|
||||
| GPS Track Uploaded | GPX/KML/GeoJSON hochgeladen (`LogEntryEditor.tsx`) | — |
|
||||
| Vessel Saved | Schiffsdaten gespeichert (`VesselForm.tsx`) | — |
|
||||
| Crew Saved | Skipper- oder Crew-Profil gespeichert (`CrewForm.tsx`) | `role`: `skipper` \| `crew`, `action`: `create` \| `update` |
|
||||
| Account Deleted | Konto erfolgreich gelöscht (`auth.ts`) | — |
|
||||
| Onboarding Tour Completed | Onboarding-Tour bis zum letzten Schritt durchlaufen (`AppTourContext.tsx`) | `mode`: `demo` (optional, bei Public-Demo) |
|
||||
| Onboarding Tour Skipped | Tour vorzeitig beendet (Skip, Escape, Backdrop, `stopTour`) | `step`: Tour-Schritt-ID (z.B. `welcome`, `entry_track`), optional `mode`: `demo` |
|
||||
| Demo Opened | Public-Demo unter `/demo` geöffnet (`DemoViewer.tsx`) | — |
|
||||
| Invite Generated | Einladungslink erzeugt (`SettingsForm.tsx`) | — |
|
||||
| Invite Accepted | Einladung angenommen und Logbuch beigetreten (`InvitationAcceptance.tsx`) | — |
|
||||
| PDF Exported | PDF-Export eines Reisetags (`LogEntryEditor.tsx`, `LogEntriesList.tsx`) | `scope`: `entry` |
|
||||
| CSV Exported | CSV-Download aus der Eintragsliste (`LogEntriesList.tsx`) | — |
|
||||
| CSV Shared | CSV über Web Share API geteilt (`LogEntriesList.tsx`) | — |
|
||||
| Photo Uploaded | Foto hochgeladen (`PhotoCapture.tsx`, `CrewForm.tsx`) | `context`: `logbook` \| `crew`, bei Crew zusätzlich `role`: `skipper` \| `crew` |
|
||||
| Backup Exported | Backup-Datei heruntergeladen (`LogbookBackupPanel.tsx`) | `entries`, `photos` (Anzahlen, keine Inhalte) |
|
||||
| Backup Restored | Backup wiederhergestellt (`LogbookBackupPanel.tsx`) | `entries`, `photos`, `mode`: `same_id` \| `overwrite` \| `new_id` |
|
||||
| Push Enabled | Crew-Änderungs-Push aktiviert (`PushNotificationSettings.tsx`) | — |
|
||||
| Push Disabled | Crew-Änderungs-Push deaktiviert (`PushNotificationSettings.tsx`) | — |
|
||||
| Footer Link Clicked | Klick auf Autoren-Link im App-Footer (`AppFooter.tsx`) | — |
|
||||
|
||||
## Bewusst nicht getrackt
|
||||
|
||||
- **Demo-Logbuch:** Beim automatischen Seed (`demoLogbook.ts`) werden keine Events ausgelöst — nur echte Nutzeraktionen zählen.
|
||||
- **Manuelle Signaturen:** Nur Passkey-Signaturen lösen `Entry Signed` aus.
|
||||
- **PII:** Keine Inhalte aus verschlüsselten Logbüchern in Properties.
|
||||
|
||||
## Typische Funnels (Plausible Goals)
|
||||
|
||||
Empfohlene Goal-Ketten für Auswertung:
|
||||
|
||||
1. **Aktivierung:** Account Created → Logbook Created → Travel Day Created → Travel Day Saved
|
||||
2. **Onboarding:** Account Created → Onboarding Tour Completed (vs. Onboarding Tour Skipped)
|
||||
3. **Kollaboration:** Invite Generated → Invite Accepted
|
||||
4. **Export:** Travel Day Saved → PDF Exported / CSV Exported
|
||||
5. **Datensicherung:** Backup Exported → Backup Restored
|
||||
|
||||
## Entwicklung
|
||||
|
||||
```ts
|
||||
import { PlausibleEvents, trackPlausibleEvent } from './services/analytics.js'
|
||||
|
||||
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
|
||||
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'skipper' })
|
||||
```
|
||||
|
||||
Lokal ohne Plausible-Script ist `trackPlausibleEvent` ein No-Op. In Production im Browser-Netzwerk-Tab auf Requests an die Plausible-Instanz prüfen.
|
||||
@@ -0,0 +1,417 @@
|
||||
# Implementierungsplan: Push-Benachrichtigungen für Logbuch-Owner
|
||||
|
||||
**Ziel:** Der Owner eines Logbuchs soll per Web Push informiert werden, wenn ein eingeladenes Crewmitglied (Collaborator mit WRITE) Änderungen synchronisiert — auch wenn die App geschlossen ist.
|
||||
|
||||
**Stand Codebase:** Service Worker nur für PWA-Caching/Updates (`vite-plugin-pwa`). Sync läuft per `setInterval` im Tab (~30 s). Kein `web-push`, keine Push-Subscriptions in der DB.
|
||||
|
||||
---
|
||||
|
||||
## 1. Anforderungen
|
||||
|
||||
### Funktional (MVP)
|
||||
|
||||
| ID | Anforderung |
|
||||
|----|-------------|
|
||||
| N-01 | Owner kann Push-Benachrichtigungen global aktivieren/deaktivieren (Opt-in). |
|
||||
| N-02 | Bei erfolgreichem Sync-Push durch einen **Nicht-Owner-Collaborator** erhält der Owner **eine** zusammengefasste Benachrichtigung pro Logbuch und Request (nicht pro Queue-Item). |
|
||||
| N-03 | Klick auf die Benachrichtigung öffnet die App auf dem betroffenen Logbuch (Deep-Link `/logbook/:id` o. ä.). |
|
||||
| N-04 | Benachrichtigungstext ist **generisch** (Zero-Knowledge: Server kann Titel/Inhalt nicht lesen). |
|
||||
| N-05 | DE/EN über i18n-Keys; Sprache aus Browser/`Accept-Language` oder gespeicherter App-Sprache in Subscription-Metadaten. |
|
||||
| N-06 | Abgelaufene/ungültige Subscriptions werden beim Fehlerversand gelöscht (410 Gone). |
|
||||
|
||||
### Nicht im MVP (später)
|
||||
|
||||
- Push an Collaborators bei Owner-Änderungen (bidirektional).
|
||||
- Pro-Logbuch Ein/Aus (nur global reicht zunächst).
|
||||
- Inhaltliche Details („Eintrag #3 bearbeitet“) — würde Klartext auf dem Server erfordern.
|
||||
- E-Mail/SMS als Fallback.
|
||||
- „Quiet hours“ / Do-not-disturb-Zeiten.
|
||||
|
||||
### Akzeptanzkriterien (UAT)
|
||||
|
||||
1. Owner aktiviert Push in den Einstellungen → Browser fragt Berechtigung → Subscription liegt in DB.
|
||||
2. Collaborator bearbeitet Eintrag, App des Collaborators synct → Owner erhält Push innerhalb weniger Sekunden (Gerät online, Berechtigung erteilt).
|
||||
3. Owner mit deaktivierten Push-Einstellungen erhält nichts.
|
||||
4. Bulk-Sync (10 Items) → genau **eine** Push-Nachricht.
|
||||
5. Klick öffnet installierte PWA oder Browser-Tab mit korrektem Logbuch.
|
||||
|
||||
---
|
||||
|
||||
## 2. Architektur
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Crew as Crew-Client
|
||||
participant API as Express API
|
||||
participant DB as PostgreSQL
|
||||
participant Push as web-push (VAPID)
|
||||
participant SW as Service Worker (Owner)
|
||||
participant Owner as Owner-Gerät
|
||||
|
||||
Crew->>API: POST /api/sync/push (X-User-Id: crew)
|
||||
API->>DB: Payloads speichern
|
||||
API->>API: collaborator change? → notify owner
|
||||
API->>DB: PushSubscriptions (owner)
|
||||
API->>Push: sendNotification (pro Endpoint)
|
||||
Push->>SW: Push Event
|
||||
SW->>Owner: System-Benachrichtigung
|
||||
Owner->>SW: notificationclick
|
||||
SW->>Owner: openWindow(/logbook/:id)
|
||||
```
|
||||
|
||||
### Komponenten
|
||||
|
||||
| Schicht | Neu/Geändert | Aufgabe |
|
||||
|---------|--------------|---------|
|
||||
| **Prisma** | Neu | `PushSubscription`, optional `UserNotificationPrefs` |
|
||||
| **Server** | Neu | `routes/push.ts`, `services/pushNotify.ts`, Env VAPID |
|
||||
| **sync.ts** | Änderung | Nach erfolgreichem Collaborator-Push Owner benachrichtigen |
|
||||
| **Client SW** | Neu | Custom SW (`injectManifest`) mit `push` + `notificationclick` |
|
||||
| **Client UI** | Neu | Einstellungen: Toggle, Permission-Flow, Status |
|
||||
| **Client Service** | Neu | `pushNotifications.ts` — subscribe, unsubscribe, sync mit API |
|
||||
|
||||
---
|
||||
|
||||
## 3. Plattform- und Produkt-Hinweise
|
||||
|
||||
| Thema | Auswirkung |
|
||||
|-------|------------|
|
||||
| **iOS** | Web Push für installierte PWAs ab **iOS 16.4+**. Nutzer müssen App zum Home Screen hinzufügen und Push erlauben. |
|
||||
| **Android / Desktop** | Chrome/Edge/Firefox: gut unterstützt; PWA installiert empfohlen. |
|
||||
| **HTTPS** | Web Push nur über HTTPS (Produktion erfüllt das). |
|
||||
| **Zero-Knowledge** | Text z. B. „Neue Änderung in einem Ihrer Logbücher“ + `logbookId` nur im `data`-Payload (nicht im sichtbaren Titel nötig). |
|
||||
| **Datenschutz** | Push-Endpoints sind personenbezogen → in Datenschutzerklärung erwähnen; Löschung bei Account-Löschung (Cascade). |
|
||||
|
||||
---
|
||||
|
||||
## 4. Datenmodell (Prisma)
|
||||
|
||||
```prisma
|
||||
model PushSubscription {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
endpoint String @unique
|
||||
p256dh String // keys.p256dh (base64url)
|
||||
auth String // keys.auth (base64url)
|
||||
userAgent String? // optional, Debugging
|
||||
locale String? // "de" | "en" — für Notification-Text
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model UserNotificationPrefs {
|
||||
userId String @id
|
||||
collaboratorChangesEnabled Boolean @default(false)
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
```
|
||||
|
||||
`User`-Relationen ergänzen: `pushSubscriptions`, `notificationPrefs`.
|
||||
|
||||
**Migration:** `npx prisma migrate dev --name add_push_subscriptions`
|
||||
|
||||
---
|
||||
|
||||
## 5. Server-Implementierung
|
||||
|
||||
### 5.1 Abhängigkeit & Umgebung
|
||||
|
||||
```bash
|
||||
npm install web-push --workspace=server
|
||||
```
|
||||
|
||||
`.env` (Beispiel):
|
||||
|
||||
```env
|
||||
VAPID_PUBLIC_KEY=...
|
||||
VAPID_PRIVATE_KEY=...
|
||||
VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu
|
||||
```
|
||||
|
||||
Keys einmalig erzeugen:
|
||||
|
||||
```bash
|
||||
npx web-push generate-vapid-keys
|
||||
```
|
||||
|
||||
Öffentlichen Key zusätzlich als `VITE_VAPID_PUBLIC_KEY` für den Client (nur Public Key).
|
||||
|
||||
### 5.2 API-Routen (`/api/push`)
|
||||
|
||||
| Methode | Pfad | Auth | Beschreibung |
|
||||
|---------|------|------|--------------|
|
||||
| `GET` | `/vapid-public-key` | nein | Liefert Public Key für `pushManager.subscribe` |
|
||||
| `PUT` | `/subscription` | `X-User-Id` | Upsert Subscription (endpoint + keys) |
|
||||
| `DELETE` | `/subscription` | `X-User-Id` | Body: `{ endpoint }` — Gerät abmelden |
|
||||
| `GET` | `/prefs` | `X-User-Id` | Liest `collaboratorChangesEnabled` |
|
||||
| `PUT` | `/prefs` | `X-User-Id` | Body: `{ collaboratorChangesEnabled: boolean }` |
|
||||
|
||||
`requireUser`-Middleware wie in `sync.ts` / `collaboration.ts` wiederverwenden.
|
||||
|
||||
### 5.3 Benachrichtigungs-Service
|
||||
|
||||
**Datei:** `server/src/services/pushNotify.ts`
|
||||
|
||||
```ts
|
||||
// Pseudocode — Kernlogik
|
||||
export async function notifyOwnerOfCollaboratorChanges(
|
||||
logbookId: string,
|
||||
ownerUserId: string,
|
||||
actorUserId: string,
|
||||
changeCount: number
|
||||
): Promise<void>
|
||||
```
|
||||
|
||||
Ablauf:
|
||||
|
||||
1. `UserNotificationPrefs`: wenn `collaboratorChangesEnabled !== true` → return.
|
||||
2. Alle `PushSubscription` für `ownerUserId` laden.
|
||||
3. Payload (Web Push JSON):
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Kapteins Daagbok",
|
||||
"body": "Neue Änderung in einem Ihrer Logbücher.",
|
||||
"tag": "logbook-change-{logbookId}",
|
||||
"renotify": false,
|
||||
"data": { "url": "/logbook/{logbookId}", "logbookId": "{logbookId}", "changeCount": 3 }
|
||||
}
|
||||
```
|
||||
|
||||
4. `webpush.sendNotification(subscription, payload, options)` parallel mit `Promise.allSettled`.
|
||||
5. Bei Status **410** oder **404**: Subscription aus DB löschen.
|
||||
6. Fehler loggen, Sync-Response **nicht** fehlschlagen lassen (Push ist Best-Effort).
|
||||
|
||||
**Deduplizierung / Rate-Limit (empfohlen):**
|
||||
|
||||
- In-Memory-Map `ownerId:logbookId → lastSentAt` mit TTL 2–5 Minuten, **oder**
|
||||
- Redis/DB-Tabelle `NotificationThrottle` mit `lastSentAt`.
|
||||
|
||||
Verhindert Push-Spam bei großen Offline-Queues.
|
||||
|
||||
### 5.4 Hook in `sync.ts`
|
||||
|
||||
Nach der Schleife über `items` (oder innerhalb, mit Sammellogik):
|
||||
|
||||
```ts
|
||||
// Pro Request sammeln:
|
||||
const ownerNotifications = new Map<string, { logbookId: string; count: number }>()
|
||||
|
||||
// Bei jedem erfolgreichen Item:
|
||||
if (res.status === 'success' && !isOwner && isCollaborator) {
|
||||
if (action === 'create' || action === 'update') {
|
||||
const ownerId = logbook.userId
|
||||
const key = `${ownerId}:${logbookId}`
|
||||
const prev = ownerNotifications.get(key) ?? { logbookId, count: 0 }
|
||||
prev.count++
|
||||
ownerNotifications.set(key, prev)
|
||||
}
|
||||
}
|
||||
|
||||
// Nach der Schleife, async fire-and-forget:
|
||||
for (const [key, { logbookId, count }] of ownerNotifications) {
|
||||
const ownerId = key.split(':')[0]
|
||||
void notifyOwnerOfCollaboratorChanges(logbookId, ownerId, req.userId, count)
|
||||
}
|
||||
```
|
||||
|
||||
**Wichtig:** Owner, der selbst als „Crew“ irrtümlich synct, ist `isOwner` — kein Push.
|
||||
|
||||
**Optional später:** auch `delete`-Aktionen einbeziehen (gleiche Logik).
|
||||
|
||||
### 5.5 `index.ts`
|
||||
|
||||
```ts
|
||||
import pushRouter from './routes/push.js'
|
||||
app.use('/api/push', pushRouter)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Client-Implementierung
|
||||
|
||||
### 6.1 Service Worker (Custom `injectManifest`)
|
||||
|
||||
`vite.config.ts` anpassen:
|
||||
|
||||
```ts
|
||||
VitePWA({
|
||||
strategies: 'injectManifest',
|
||||
srcDir: 'src',
|
||||
filename: 'sw.ts',
|
||||
injectRegister: 'auto',
|
||||
// manifest unverändert
|
||||
})
|
||||
```
|
||||
|
||||
**Datei:** `client/src/sw.ts`
|
||||
|
||||
- `precacheAndRoute` von Workbox importieren (wie vite-plugin-pwa-Doku).
|
||||
- `self.addEventListener('push', …)`:
|
||||
- `event.data.json()` parsen
|
||||
- `self.registration.showNotification(title, { body, tag, data, icon: '/logo.png' })`
|
||||
- `notificationclick`:
|
||||
- `event.notification.close()`
|
||||
- `clients.openWindow(data.url || '/')` — absolute URL mit `self.location.origin`
|
||||
|
||||
**i18n im SW:** MVP mit serverseitigem `locale` in Subscription; alternativ nur EN/DE-Body vom Server senden.
|
||||
|
||||
### 6.2 Client-Service `pushNotifications.ts`
|
||||
|
||||
| Funktion | Beschreibung |
|
||||
|----------|--------------|
|
||||
| `isPushSupported()` | `'serviceWorker' in navigator && 'PushManager' in window` |
|
||||
| `getPermissionState()` | `Notification.permission` |
|
||||
| `subscribeToPush()` | SW ready → `pushManager.subscribe({ userVisibleOnly: true, applicationServerKey })` → `PUT /api/push/subscription` |
|
||||
| `unsubscribeFromPush()` | `subscription.unsubscribe()` + `DELETE` API |
|
||||
| `syncPrefs(enabled)` | `PUT /api/push/prefs` |
|
||||
| `ensureSubscriptionOnLogin()` | Wenn Prefs an und Permission granted, Subscription erneuern (Key-Rotation) |
|
||||
|
||||
`applicationServerKey`: VAPID Public Key von `GET /api/push/vapid-public-key` oder Build-Time `import.meta.env.VITE_VAPID_PUBLIC_KEY`.
|
||||
|
||||
### 6.3 UI (Settings)
|
||||
|
||||
**Ort:** `SettingsForm.tsx` (nur für Owner sichtbar, nicht bei `readOnly` / Crew-Logbuch).
|
||||
|
||||
Ablauf beim Einschalten:
|
||||
|
||||
1. `Notification.requestPermission()` — bei `denied` Hinweis + Link zu Browser-Einstellungen.
|
||||
2. `subscribeToPush()` + `syncPrefs(true)`.
|
||||
3. Bei Erfolg: grüner Status „Push aktiv“.
|
||||
|
||||
Beim Ausschalten:
|
||||
|
||||
1. `syncPrefs(false)` + optional `unsubscribeFromPush()` auf diesem Gerät.
|
||||
|
||||
**Hinweis-Banner** wenn `!isPushSupported()` oder iOS & nicht installiert → Verweis auf `PwaInstallPrompt`.
|
||||
|
||||
### 6.4 Deep-Link beim Öffnen
|
||||
|
||||
In `App.tsx` oder Router: beim Start `url` aus `notificationclick` via `clients.matchAll` nicht nötig — SW öffnet direkt.
|
||||
|
||||
Sicherstellen, dass Route `/logbook/:logbookId` (oder bestehende Logbuch-Route) existiert und Auth-Gate passiert.
|
||||
|
||||
### 6.5 Bestehenden SW-Update-Flow
|
||||
|
||||
`usePwaUpdate.ts` bleibt kompatibel mit `injectManifest`, sofern `virtual:pwa-register` weiter registriert wird — vite-plugin-pwa-Doku für `injectManifest` + React beachten.
|
||||
|
||||
---
|
||||
|
||||
## 7. Sicherheit
|
||||
|
||||
| Risiko | Maßnahme |
|
||||
|--------|----------|
|
||||
| Fremde subscriben mit fremder `userId` | Nur authentifizierte Requests (`X-User-Id` wie heute — langfristig Session/JWT erwägen). |
|
||||
| Push an falschen User | `notifyOwner` nur mit `logbook.userId` aus DB, nie aus Client-Body. |
|
||||
| Endpoint-Injection | `endpoint` muss HTTPS-URL sein; Länge begrenzen. |
|
||||
| Spam durch Crew | Rate-Limit + nur `create`/`update` im MVP. |
|
||||
| VAPID Private Key | Nur Server-Env, nie im Client. |
|
||||
|
||||
---
|
||||
|
||||
## 8. Implementierungsphasen
|
||||
|
||||
### Phase 1 — Infrastruktur (1–2 Tage)
|
||||
|
||||
- [ ] VAPID-Keys für Dev/Prod
|
||||
- [ ] Prisma-Modelle + Migration
|
||||
- [ ] `web-push` + `pushNotify.ts` + Unit-Test mit Mock-Subscription
|
||||
- [ ] Routen `/api/push/*`
|
||||
- [ ] `GET /vapid-public-key`
|
||||
|
||||
### Phase 2 — Service Worker (1 Tag)
|
||||
|
||||
- [ ] Umstellung auf `injectManifest` + `sw.ts`
|
||||
- [ ] `push` / `notificationclick` Handler
|
||||
- [ ] Manueller Test: `web-push` CLI oder kleines Admin-Skript sendet Test-Push
|
||||
|
||||
### Phase 3 — Trigger & Client-Anbindung (1–2 Tage)
|
||||
|
||||
- [ ] Hook in `sync.ts` mit Aggregation
|
||||
- [ ] `pushNotifications.ts`
|
||||
- [ ] Settings-UI + i18n (`de.json` / `en.json`)
|
||||
- [ ] Plausible-Event optional: `push_enabled`, `push_denied`
|
||||
|
||||
### Phase 4 — Härtung (1 Tag)
|
||||
|
||||
- [ ] Rate-Limit / `tag`-basierte Ersetzung gleicher Logbuch-Pushes
|
||||
- [ ] 410-Cleanup
|
||||
- [ ] README + Datenschutz-Hinweis
|
||||
- [ ] E2E-Manual-Testmatrix (iOS PWA, Android Chrome, Desktop)
|
||||
|
||||
### Phase 5 — Deployment
|
||||
|
||||
- [ ] Env-Variablen in Produktion (Docker/Hosting)
|
||||
- [ ] Nginx: `sw.js` weiterhin `no-cache` (bereits in `nginx.conf`)
|
||||
- [ ] Smoke-Test nach Deploy
|
||||
|
||||
**Geschätzter Gesamtaufwand:** 4–6 Entwicklertage für MVP.
|
||||
|
||||
---
|
||||
|
||||
## 9. Testplan
|
||||
|
||||
| # | Szenario | Erwartung |
|
||||
|---|----------|-----------|
|
||||
| T1 | Push nicht unterstützt (alter Browser) | UI zeigt „nicht verfügbar“, kein Fehler |
|
||||
| T2 | Permission denied | Toggle aus, erklärender Hinweis |
|
||||
| T3 | Owner aktiviert, Crew synct 1 Eintrag | 1 Push |
|
||||
| T4 | Crew synct 5 Einträge in einem Request | 1 Push |
|
||||
| T5 | Owner Prefs aus | Kein Push |
|
||||
| T6 | Ungültige Subscription | 410 → DB-Eintrag weg, nächster Push an andere Geräte ok |
|
||||
| T7 | notificationclick | App öffnet richtiges Logbuch |
|
||||
| T8 | Owner ändert selbst | Kein Push an sich selbst |
|
||||
|
||||
**Dev-Test ohne zweites Gerät:** Zwei Browser-Profile (Owner + Crew), Crew-Einladung wie in Produktion.
|
||||
|
||||
---
|
||||
|
||||
## 10. Offene Entscheidungen (vor Start klären)
|
||||
|
||||
1. **Nur Owner oder auch andere Collaborators?** — MVP: nur Owner.
|
||||
2. **Rate-Limit-Dauer:** 2 min vs. 5 min — Empfehlung: **3 min** pro Logbuch.
|
||||
3. **Mehrere Geräte des Owners:** alle Subscriptions benachrichtigen — ja (Standard).
|
||||
4. **Auth verbessern:** Push-Routen jetzt mit `X-User-Id` wie Rest der API; Roadmap-Item: echte Session.
|
||||
|
||||
---
|
||||
|
||||
## 11. Referenzen
|
||||
|
||||
- [web-push (npm)](https://www.npmjs.com/package/web-push)
|
||||
- [MDN: Push API](https://developer.mozilla.org/en-US/docs/Web/API/Push_API)
|
||||
- [vite-plugin-pwa: injectManifest](https://vite-pwa-org.netlify.app/guide/inject-manifest.html)
|
||||
- [Apple: Web Push for PWAs (iOS 16.4+)](https://webkit.org/blog/13878/web-push-for-web-apps-on-ios-and-ipados/)
|
||||
|
||||
---
|
||||
|
||||
## 12. Datei-Checkliste (neu/geändert)
|
||||
|
||||
```
|
||||
server/
|
||||
prisma/schema.prisma # PushSubscription, UserNotificationPrefs
|
||||
prisma/migrations/.../
|
||||
src/routes/push.ts # neu
|
||||
src/services/pushNotify.ts # neu
|
||||
src/routes/sync.ts # Hook notifyOwner
|
||||
src/index.ts # Router mount
|
||||
package.json # web-push
|
||||
|
||||
client/
|
||||
src/sw.ts # neu (injectManifest)
|
||||
vite.config.ts # strategies: injectManifest
|
||||
src/services/pushNotifications.ts # neu
|
||||
src/components/PushNotificationSettings.tsx # neu (optional)
|
||||
src/components/SettingsForm.tsx # Integration
|
||||
src/i18n/locales/de.json, en.json
|
||||
.env.example # VITE_VAPID_PUBLIC_KEY
|
||||
|
||||
docs/
|
||||
push-notifications-plan.md # dieses Dokument
|
||||
README.md # Feature-Zeile + Env-Hinweis
|
||||
```
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generates the beta flyer PDF from docs/marketing/beta-flyer.html
|
||||
* Usage: npm run generate:flyer --prefix client
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process'
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const repoRoot = resolve(__dirname, '..')
|
||||
const clientDir = resolve(repoRoot, 'client')
|
||||
const marketingDir = resolve(repoRoot, 'docs/marketing')
|
||||
const assetsDir = resolve(marketingDir, 'assets')
|
||||
const htmlPath = resolve(marketingDir, 'beta-flyer.html')
|
||||
const qrPath = resolve(assetsDir, 'qr-kapteins-daagbok.eu.png')
|
||||
const pdfPath = resolve(marketingDir, 'kapteins-daagbok-beta-flyer.pdf')
|
||||
const appUrl = 'https://kapteins-daagbok.eu'
|
||||
|
||||
const require = createRequire(resolve(clientDir, 'package.json'))
|
||||
|
||||
function isMissingBrowserError(err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
return msg.includes("Executable doesn't exist") || msg.includes('browserType.launch')
|
||||
}
|
||||
|
||||
async function ensurePlaywrightChromium(playwright) {
|
||||
try {
|
||||
const browser = await playwright.chromium.launch({ headless: true })
|
||||
await browser.close()
|
||||
return
|
||||
} catch (err) {
|
||||
if (!isMissingBrowserError(err)) throw err
|
||||
}
|
||||
|
||||
console.log('Playwright Chromium fehlt — installiere Browser (einmalig)…')
|
||||
execSync('npx playwright install chromium', {
|
||||
cwd: clientDir,
|
||||
stdio: 'inherit'
|
||||
})
|
||||
}
|
||||
|
||||
async function ensureQrCode() {
|
||||
let QRCode
|
||||
try {
|
||||
QRCode = require('qrcode')
|
||||
} catch {
|
||||
console.error('Fehlende Abhängigkeit: "npm install -D qrcode playwright" in client/ ausführen.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await mkdir(assetsDir, { recursive: true })
|
||||
const png = await QRCode.toBuffer(appUrl, {
|
||||
type: 'png',
|
||||
width: 512,
|
||||
margin: 1,
|
||||
color: { dark: '#0f172a', light: '#ffffff' }
|
||||
})
|
||||
await writeFile(qrPath, png)
|
||||
console.log('QR code written:', qrPath)
|
||||
}
|
||||
|
||||
async function renderPdf() {
|
||||
let playwright
|
||||
try {
|
||||
playwright = require('playwright')
|
||||
} catch {
|
||||
console.error('Fehlende Abhängigkeit: "npm install -D playwright" in client/ ausführen.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await ensurePlaywrightChromium(playwright)
|
||||
|
||||
const browser = await playwright.chromium.launch({ headless: true })
|
||||
try {
|
||||
const page = await browser.newPage()
|
||||
await page.goto(pathToFileURL(htmlPath).href, { waitUntil: 'networkidle' })
|
||||
await page.pdf({
|
||||
path: pdfPath,
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
preferCSSPageSize: true,
|
||||
margin: { top: 0, right: 0, bottom: 0, left: 0 }
|
||||
})
|
||||
console.log('PDF written:', pdfPath)
|
||||
} finally {
|
||||
await browser.close()
|
||||
}
|
||||
}
|
||||
|
||||
await ensureQrCode()
|
||||
await renderPdf()
|
||||
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generates demo GPX tracks (Laboe→Damp, Damp→Schleimünde) in Kapteins Daagbok format.
|
||||
*/
|
||||
import { writeFileSync, mkdirSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const outDir = join(__dirname, '../client/src/assets/demo')
|
||||
|
||||
const NM_IN_METERS = 1852
|
||||
|
||||
function haversineMeters(lat1, lon1, lat2, lon2) {
|
||||
const R = 6371000
|
||||
const toRad = (d) => (d * Math.PI) / 180
|
||||
const dLat = toRad(lat2 - lat1)
|
||||
const dLon = toRad(lon2 - lon1)
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2
|
||||
return 2 * R * Math.asin(Math.sqrt(a))
|
||||
}
|
||||
|
||||
function bearingDeg(lat1, lon1, lat2, lon2) {
|
||||
const toRad = (d) => (d * Math.PI) / 180
|
||||
const toDeg = (r) => (r * 180) / Math.PI
|
||||
const φ1 = toRad(lat1)
|
||||
const φ2 = toRad(lat2)
|
||||
const Δλ = toRad(lon2 - lon1)
|
||||
const y = Math.sin(Δλ) * Math.cos(φ2)
|
||||
const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ)
|
||||
return (toDeg(Math.atan2(y, x)) + 360) % 360
|
||||
}
|
||||
|
||||
function generateTrack({ name, desc, start, end, distanceNm, startTime, avgSpeedKn = 4.5 }) {
|
||||
const totalM = distanceNm * NM_IN_METERS
|
||||
const numPoints = Math.max(40, Math.round(distanceNm * 25))
|
||||
const course = bearingDeg(start.lat, start.lon, end.lat, end.lon)
|
||||
const durationSec = (distanceNm / avgSpeedKn) * 3600
|
||||
const startMs = new Date(startTime).getTime()
|
||||
|
||||
const points = []
|
||||
for (let i = 0; i < numPoints; i++) {
|
||||
const t = i / (numPoints - 1)
|
||||
const lat = start.lat + (end.lat - start.lat) * t
|
||||
const lon = start.lon + (end.lon - start.lon) * t
|
||||
const ts = new Date(startMs + durationSec * t * 1000).toISOString()
|
||||
const speedMs = (avgSpeedKn / 1.94384) * (0.85 + 0.3 * Math.sin(i * 0.4))
|
||||
points.push({ lat, lon, ts, speedMs, course })
|
||||
}
|
||||
|
||||
// Rescale last segment to hit target distance approximately
|
||||
let acc = 0
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
acc += haversineMeters(points[i - 1].lat, points[i - 1].lon, points[i].lat, points[i].lon)
|
||||
}
|
||||
const scale = totalM / acc
|
||||
const adjusted = [{ ...points[0] }]
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const prev = adjusted[i - 1]
|
||||
const raw = points[i]
|
||||
const seg = haversineMeters(prev.lat, prev.lon, raw.lat, raw.lon) * scale
|
||||
const bearing = bearingDeg(prev.lat, prev.lon, raw.lat, raw.lon)
|
||||
const R = 6371000
|
||||
const br = (bearing * Math.PI) / 180
|
||||
const lat1 = (prev.lat * Math.PI) / 180
|
||||
const lon1 = (prev.lon * Math.PI) / 180
|
||||
const lat2 = Math.asin(
|
||||
Math.sin(lat1) * Math.cos(seg / R) + Math.cos(lat1) * Math.sin(seg / R) * Math.cos(br)
|
||||
)
|
||||
const lon2 =
|
||||
lon1 +
|
||||
Math.atan2(
|
||||
Math.sin(br) * Math.sin(seg / R) * Math.cos(lat1),
|
||||
Math.cos(seg / R) - Math.sin(lat1) * Math.sin(lat2)
|
||||
)
|
||||
adjusted.push({
|
||||
lat: (lat2 * 180) / Math.PI,
|
||||
lon: (lon2 * 180) / Math.PI,
|
||||
ts: raw.ts,
|
||||
speedMs: raw.speedMs,
|
||||
course: raw.course
|
||||
})
|
||||
}
|
||||
adjusted[adjusted.length - 1] = { ...adjusted.at(-1), lat: end.lat, lon: end.lon }
|
||||
|
||||
const trkpts = adjusted
|
||||
.map(
|
||||
(p) => ` <trkpt lat="${p.lat.toFixed(6)}" lon="${p.lon.toFixed(6)}">
|
||||
<time>${p.ts}</time>
|
||||
<ele>1.0</ele>
|
||||
<speed>${p.speedMs.toFixed(3)}</speed>
|
||||
<course>${p.course.toFixed(1)}</course>
|
||||
</trkpt>`
|
||||
)
|
||||
.join('\n')
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="Kapteins Daagbok Demo" xmlns="http://www.topografix.com/GPX/1/1">
|
||||
<metadata>
|
||||
<name>${name}</name>
|
||||
<desc>${desc}</desc>
|
||||
<time>${startTime}</time>
|
||||
</metadata>
|
||||
<trk>
|
||||
<name>${name}</name>
|
||||
<type>sailing</type>
|
||||
<trkseg>
|
||||
${trkpts}
|
||||
</trkseg>
|
||||
</trk>
|
||||
</gpx>
|
||||
`
|
||||
}
|
||||
|
||||
mkdirSync(outDir, { recursive: true })
|
||||
|
||||
const laboeDamp = generateTrack({
|
||||
name: 'Laboe → Damp',
|
||||
desc: 'Demo track Laboe to Damp, ~8 sm',
|
||||
start: { lat: 54.397929, lon: 10.224316 },
|
||||
end: { lat: 54.455, lon: 10.729 },
|
||||
distanceNm: 8,
|
||||
startTime: '2026-05-30T09:00:00Z',
|
||||
avgSpeedKn: 4.2
|
||||
})
|
||||
|
||||
const dampSchleimuende = generateTrack({
|
||||
name: 'Damp → Schleimünde',
|
||||
desc: 'Demo track Damp to Schleimünde, ~12 sm',
|
||||
start: { lat: 54.455, lon: 10.729 },
|
||||
end: { lat: 54.493, lon: 9.933 },
|
||||
distanceNm: 12,
|
||||
startTime: '2026-05-31T08:30:00Z',
|
||||
avgSpeedKn: 4.8
|
||||
})
|
||||
|
||||
writeFileSync(join(outDir, 'laboe-damp.gpx'), laboeDamp, 'utf8')
|
||||
writeFileSync(join(outDir, 'damp-schleimuende.gpx'), dampSchleimuende, 'utf8')
|
||||
console.log('Wrote laboe-damp.gpx and damp-schleimuende.gpx to', outDir)
|
||||
+80
-4
@@ -3,12 +3,66 @@
|
||||
# Configuration
|
||||
SERVER_PORT=5000
|
||||
CLIENT_PORT=5173
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
resolve_node_toolchain() {
|
||||
# Common install locations when login shell PATH is not loaded
|
||||
if command -v npm >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -s "$HOME/.nvm/nvm.sh" ]; then
|
||||
# shellcheck disable=SC1090
|
||||
. "$HOME/.nvm/nvm.sh"
|
||||
elif [ -s "/usr/local/nvm/nvm.sh" ]; then
|
||||
# shellcheck disable=SC1090
|
||||
. "/usr/local/nvm/nvm.sh"
|
||||
fi
|
||||
|
||||
if [ -d "$HOME/.fnm" ] && command -v fnm >/dev/null 2>&1; then
|
||||
eval "$(fnm env)"
|
||||
fi
|
||||
|
||||
for candidate in \
|
||||
/usr/local/bin/npm \
|
||||
/usr/bin/npm \
|
||||
"$HOME/.local/share/fnm/current/bin/npm" \
|
||||
"$HOME/.nvm/versions/node/"*/bin/npm; do
|
||||
if [ -x "$candidate" ]; then
|
||||
export PATH="$(dirname "$candidate"):$PATH"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
command -v npm >/dev/null 2>&1
|
||||
}
|
||||
|
||||
require_node_toolchain() {
|
||||
if resolve_node_toolchain; then
|
||||
echo "Using Node $(node -v), npm $(npm -v)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Error: npm was not found in PATH."
|
||||
echo ""
|
||||
echo "This script starts local Vite/Express dev servers and requires Node.js 20+ with npm."
|
||||
echo "Install Node.js on this machine, or use the Docker-based stack instead:"
|
||||
echo " ./scripts/start-dev-docker.sh"
|
||||
echo ""
|
||||
echo "On the production host, prefer updating the running stack:"
|
||||
echo " docker compose -f docker-compose.yml up -d --build"
|
||||
echo " # or from your workstation: ./scripts/update-prod.sh"
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "========================================"
|
||||
echo " Kapteins Daagbok Dev Environment "
|
||||
echo "========================================"
|
||||
echo "Preparing to (re)start services..."
|
||||
|
||||
require_node_toolchain
|
||||
|
||||
# Clean up processes running on ports
|
||||
cleanup_port() {
|
||||
local port=$1
|
||||
@@ -77,18 +131,40 @@ fi
|
||||
|
||||
# Start backend server
|
||||
echo "Starting backend API server..."
|
||||
cd server
|
||||
cd "$REPO_ROOT/server" || exit 1
|
||||
if [ ! -d node_modules ]; then
|
||||
echo "Error: server/node_modules missing. Run: cd server && npm ci"
|
||||
exit 1
|
||||
fi
|
||||
npm run dev &
|
||||
cd ..
|
||||
BACKEND_PID=$!
|
||||
cd "$REPO_ROOT" || exit 1
|
||||
|
||||
# Sleep briefly to let server start up
|
||||
sleep 1.5
|
||||
if ! kill -0 "$BACKEND_PID" 2>/dev/null; then
|
||||
echo "Error: Backend dev server exited immediately. Check server logs above."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Start frontend client
|
||||
echo "Starting frontend dev server..."
|
||||
cd client
|
||||
cd "$REPO_ROOT/client" || exit 1
|
||||
if [ ! -d node_modules ]; then
|
||||
echo "Error: client/node_modules missing. Run: cd client && npm ci"
|
||||
kill "$BACKEND_PID" 2>/dev/null
|
||||
exit 1
|
||||
fi
|
||||
npm run dev &
|
||||
cd ..
|
||||
CLIENT_PID=$!
|
||||
cd "$REPO_ROOT" || exit 1
|
||||
|
||||
sleep 1.5
|
||||
if ! kill -0 "$CLIENT_PID" 2>/dev/null; then
|
||||
echo "Error: Frontend dev server exited immediately. Check client logs above."
|
||||
kill "$BACKEND_PID" 2>/dev/null
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "========================================"
|
||||
echo "Dev services are now running:"
|
||||
|
||||
+19
-3
@@ -143,10 +143,26 @@ APP_VERSION="$6"
|
||||
|
||||
cd "$REMOTE_DIR" || { echo "Error: Remote directory '$REMOTE_DIR' not found."; exit 1; }
|
||||
|
||||
echo "Pulling latest changes from Git..."
|
||||
git pull --tags
|
||||
echo "Syncing repository from origin..."
|
||||
CURRENT_BRANCH="$(git branch --show-current)"
|
||||
if [ -z "$CURRENT_BRANCH" ]; then
|
||||
echo "Error: Could not determine current Git branch."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! git diff-index --quiet HEAD -- || [ -n "$(git status --porcelain)" ]; then
|
||||
echo "Warning: Local changes on deployment host will be discarded."
|
||||
fi
|
||||
|
||||
git fetch --tags origin
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Error: Git pull failed."
|
||||
echo "Error: Git fetch failed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git reset --hard "origin/${CURRENT_BRANCH}"
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Error: Git reset to origin/${CURRENT_BRANCH} failed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
Generated
+155
-1
@@ -13,12 +13,14 @@
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"prisma": "^5.10.2"
|
||||
"prisma": "^5.10.2",
|
||||
"web-push": "^3.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.11.24",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"tsx": "^4.7.1",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
@@ -762,6 +764,16 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/web-push": {
|
||||
"version": "3.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
|
||||
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
@@ -775,12 +787,33 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asn1.js": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
||||
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bn.js": "^4.0.0",
|
||||
"inherits": "^2.0.1",
|
||||
"minimalistic-assert": "^1.0.0",
|
||||
"safer-buffer": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/asn1js": {
|
||||
"version": "3.0.10",
|
||||
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
|
||||
@@ -795,6 +828,12 @@
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bn.js": {
|
||||
"version": "4.12.3",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
|
||||
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.5",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
|
||||
@@ -819,6 +858,12 @@
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -973,6 +1018,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
@@ -1253,6 +1307,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/http_ece": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
||||
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
@@ -1273,6 +1336,42 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
@@ -1300,6 +1399,27 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -1369,6 +1489,21 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/minimalistic-assert": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
@@ -1800,6 +1935,25 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/web-push": {
|
||||
"version": "3.6.7",
|
||||
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
||||
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"asn1.js": "^5.3.0",
|
||||
"http_ece": "1.2.0",
|
||||
"https-proxy-agent": "^7.0.0",
|
||||
"jws": "^4.0.0",
|
||||
"minimist": "^1.2.5"
|
||||
},
|
||||
"bin": {
|
||||
"web-push": "src/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
|
||||
+3
-1
@@ -15,12 +15,14 @@
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"prisma": "^5.10.2"
|
||||
"prisma": "^5.10.2",
|
||||
"web-push": "^3.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.11.24",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"tsx": "^4.7.1",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
|
||||
@@ -20,6 +20,32 @@ model User {
|
||||
credentials Credential[]
|
||||
logbooks Logbook[]
|
||||
collaborations Collaboration[]
|
||||
pushSubscriptions PushSubscription[]
|
||||
notificationPrefs UserNotificationPrefs?
|
||||
}
|
||||
|
||||
model PushSubscription {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
endpoint String @unique
|
||||
p256dh String
|
||||
auth String
|
||||
userAgent String?
|
||||
locale String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model UserNotificationPrefs {
|
||||
userId String @id
|
||||
collaboratorChangesEnabled Boolean @default(false)
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Credential {
|
||||
|
||||
+12
-1
@@ -1,14 +1,22 @@
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import dotenv from 'dotenv'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import authRouter from './routes/auth.js'
|
||||
import logbooksRouter from './routes/logbooks.js'
|
||||
import syncRouter from './routes/sync.js'
|
||||
import collaborationRouter from './routes/collaboration.js'
|
||||
import signRouter from './routes/sign.js'
|
||||
import pushRouter from './routes/push.js'
|
||||
import weatherRouter from './routes/weather.js'
|
||||
import feedbackRouter from './routes/feedback.js'
|
||||
import { prisma } from './db.js'
|
||||
|
||||
dotenv.config()
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
dotenv.config({ path: resolve(__dirname, '../../.env') })
|
||||
dotenv.config({ path: resolve(__dirname, '../.env') })
|
||||
|
||||
const app = express()
|
||||
const PORT = process.env.PORT || 5000
|
||||
@@ -22,6 +30,9 @@ app.use('/api/logbooks', logbooksRouter)
|
||||
app.use('/api/sync', syncRouter)
|
||||
app.use('/api/collaboration', collaborationRouter)
|
||||
app.use('/api/sign', signRouter)
|
||||
app.use('/api/push', pushRouter)
|
||||
app.use('/api/weather', weatherRouter)
|
||||
app.use('/api/feedback', feedbackRouter)
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/api/health', async (req, res) => {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Router } from 'express'
|
||||
import { isNtfyConfigured, sendFeedbackViaNtfy } from '../services/ntfyNotify.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const VALID_CATEGORIES = new Set(['bug', 'feature', 'general'])
|
||||
const MAX_MESSAGE_LENGTH = 2000
|
||||
const MAX_EMAIL_LENGTH = 254
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
function parseOptionalEmail(value: unknown): string | undefined {
|
||||
if (value === undefined || value === null || value === '') return undefined
|
||||
if (typeof value !== 'string') return undefined
|
||||
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return undefined
|
||||
if (trimmed.length > MAX_EMAIL_LENGTH) return undefined
|
||||
if (!EMAIL_PATTERN.test(trimmed)) return undefined
|
||||
|
||||
return trimmed
|
||||
}
|
||||
|
||||
const requireUser = (req: any, res: any, next: any) => {
|
||||
const userId = req.headers['x-user-id']
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' })
|
||||
}
|
||||
req.userId = userId
|
||||
next()
|
||||
}
|
||||
|
||||
router.get('/status', requireUser, (_req, res) => {
|
||||
res.json({ enabled: isNtfyConfigured() })
|
||||
})
|
||||
|
||||
router.post('/', requireUser, async (req: any, res) => {
|
||||
try {
|
||||
if (!isNtfyConfigured()) {
|
||||
return res.status(503).json({ error: 'Feedback is not configured on this server' })
|
||||
}
|
||||
|
||||
const { category, message, username, contactEmail, logbookId, logbookTitle, appVersion, pageUrl } =
|
||||
req.body ?? {}
|
||||
|
||||
if (typeof category !== 'string' || !VALID_CATEGORIES.has(category)) {
|
||||
return res.status(400).json({ error: 'Invalid category' })
|
||||
}
|
||||
|
||||
if (typeof message !== 'string' || !message.trim()) {
|
||||
return res.status(400).json({ error: 'Message is required' })
|
||||
}
|
||||
|
||||
const trimmedMessage = message.trim()
|
||||
if (trimmedMessage.length > MAX_MESSAGE_LENGTH) {
|
||||
return res.status(400).json({ error: `Message must be at most ${MAX_MESSAGE_LENGTH} characters` })
|
||||
}
|
||||
|
||||
let parsedContactEmail: string | undefined
|
||||
if (contactEmail !== undefined && contactEmail !== null && String(contactEmail).trim()) {
|
||||
parsedContactEmail = parseOptionalEmail(contactEmail)
|
||||
if (!parsedContactEmail) {
|
||||
return res.status(400).json({ error: 'Invalid email address' })
|
||||
}
|
||||
}
|
||||
|
||||
await sendFeedbackViaNtfy({
|
||||
category,
|
||||
message: trimmedMessage,
|
||||
username: typeof username === 'string' ? username.trim() : undefined,
|
||||
contactEmail: parsedContactEmail,
|
||||
userId: req.userId,
|
||||
logbookId: typeof logbookId === 'string' ? logbookId.trim() : undefined,
|
||||
logbookTitle: typeof logbookTitle === 'string' ? logbookTitle.trim() : undefined,
|
||||
appVersion: typeof appVersion === 'string' ? appVersion.trim() : undefined,
|
||||
pageUrl: typeof pageUrl === 'string' ? pageUrl.trim() : undefined
|
||||
})
|
||||
|
||||
return res.json({ ok: true })
|
||||
} catch (error: any) {
|
||||
console.error('Error sending feedback via Ntfy:', error)
|
||||
return res.status(502).json({ error: error.message || 'Failed to send feedback' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,139 @@
|
||||
import { Router } from 'express'
|
||||
import { prisma } from '../db.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const requireUser = (req: any, res: any, next: any) => {
|
||||
const userId = req.headers['x-user-id']
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' })
|
||||
}
|
||||
req.userId = userId
|
||||
next()
|
||||
}
|
||||
|
||||
function isValidHttpsEndpoint(endpoint: unknown): endpoint is string {
|
||||
if (typeof endpoint !== 'string' || endpoint.length > 2048) return false
|
||||
try {
|
||||
const url = new URL(endpoint)
|
||||
return url.protocol === 'https:'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/vapid-public-key', (_req, res) => {
|
||||
const publicKey = process.env.VAPID_PUBLIC_KEY
|
||||
if (!publicKey) {
|
||||
return res.status(503).json({ error: 'Push notifications are not configured on this server' })
|
||||
}
|
||||
return res.json({ publicKey })
|
||||
})
|
||||
|
||||
router.use(requireUser)
|
||||
|
||||
router.get('/prefs', async (req: any, res) => {
|
||||
try {
|
||||
const prefs = await prisma.userNotificationPrefs.findUnique({
|
||||
where: { userId: req.userId }
|
||||
})
|
||||
return res.json({
|
||||
collaboratorChangesEnabled: prefs?.collaboratorChangesEnabled ?? false
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Error reading push prefs:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
router.put('/prefs', async (req: any, res) => {
|
||||
try {
|
||||
const { collaboratorChangesEnabled } = req.body
|
||||
if (typeof collaboratorChangesEnabled !== 'boolean') {
|
||||
return res.status(400).json({ error: 'collaboratorChangesEnabled must be a boolean' })
|
||||
}
|
||||
|
||||
const prefs = await prisma.userNotificationPrefs.upsert({
|
||||
where: { userId: req.userId },
|
||||
create: {
|
||||
userId: req.userId,
|
||||
collaboratorChangesEnabled,
|
||||
updatedAt: new Date()
|
||||
},
|
||||
update: {
|
||||
collaboratorChangesEnabled,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
})
|
||||
|
||||
return res.json({
|
||||
collaboratorChangesEnabled: prefs.collaboratorChangesEnabled
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Error updating push prefs:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
router.put('/subscription', async (req: any, res) => {
|
||||
try {
|
||||
const { endpoint, keys, locale, userAgent } = req.body
|
||||
if (!isValidHttpsEndpoint(endpoint)) {
|
||||
return res.status(400).json({ error: 'Invalid push subscription endpoint' })
|
||||
}
|
||||
if (!keys?.p256dh || !keys?.auth || typeof keys.p256dh !== 'string' || typeof keys.auth !== 'string') {
|
||||
return res.status(400).json({ error: 'Invalid subscription keys' })
|
||||
}
|
||||
|
||||
const normalizedLocale =
|
||||
typeof locale === 'string' && (locale === 'de' || locale === 'en') ? locale : null
|
||||
|
||||
await prisma.pushSubscription.upsert({
|
||||
where: { endpoint },
|
||||
create: {
|
||||
userId: req.userId,
|
||||
endpoint,
|
||||
p256dh: keys.p256dh,
|
||||
auth: keys.auth,
|
||||
locale: normalizedLocale,
|
||||
userAgent: typeof userAgent === 'string' ? userAgent.slice(0, 512) : null
|
||||
},
|
||||
update: {
|
||||
userId: req.userId,
|
||||
p256dh: keys.p256dh,
|
||||
auth: keys.auth,
|
||||
locale: normalizedLocale,
|
||||
userAgent: typeof userAgent === 'string' ? userAgent.slice(0, 512) : null,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
})
|
||||
|
||||
return res.json({ success: true })
|
||||
} catch (error: any) {
|
||||
console.error('Error saving push subscription:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/subscription', async (req: any, res) => {
|
||||
try {
|
||||
const { endpoint } = req.body
|
||||
if (!isValidHttpsEndpoint(endpoint)) {
|
||||
return res.status(400).json({ error: 'Invalid push subscription endpoint' })
|
||||
}
|
||||
|
||||
await prisma.pushSubscription.deleteMany({
|
||||
where: {
|
||||
endpoint,
|
||||
userId: req.userId
|
||||
}
|
||||
})
|
||||
|
||||
return res.json({ success: true })
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting push subscription:', error)
|
||||
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -61,6 +61,7 @@ async function getLogbookWithAccess(logbookId: string, userId: string) {
|
||||
}
|
||||
|
||||
function hasWriteAccess(access: { isOwner: boolean; collaboration?: { role: string } | null }) {
|
||||
// Intentional (HYBRID-ELECTRONIC-SIGNATURES.md §2.1): owner OR WRITE collaborator may sign entries.
|
||||
return access.isOwner || access.collaboration?.role === 'WRITE'
|
||||
}
|
||||
|
||||
@@ -106,6 +107,7 @@ async function isAuthorizedSigner(
|
||||
role: 'skipper' | 'crew'
|
||||
): Promise<boolean> {
|
||||
if (role === 'skipper') {
|
||||
// Skipper signing: owner or WRITE collaborator (design §2.1), using their own passkey.
|
||||
if (signerUserId === ownerUserId) return true
|
||||
const collaboration = await prisma.collaboration.findUnique({
|
||||
where: {
|
||||
|
||||
+62
-19
@@ -1,5 +1,6 @@
|
||||
import { Router } from 'express'
|
||||
import { prisma } from '../db.js'
|
||||
import { notifyOwnerOfCollaboratorChanges } from '../services/pushNotify.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -24,6 +25,27 @@ router.post('/push', async (req: any, res) => {
|
||||
}
|
||||
|
||||
const results = []
|
||||
const ownerNotifications = new Map<
|
||||
string,
|
||||
{ ownerId: string; logbookId: string; count: number }
|
||||
>()
|
||||
|
||||
const recordCollaboratorChange = (
|
||||
ownerId: string,
|
||||
logbookId: string,
|
||||
isOwner: boolean,
|
||||
isCollaborator: unknown,
|
||||
action: string,
|
||||
type: string
|
||||
) => {
|
||||
if (isOwner || !isCollaborator) return
|
||||
if (action !== 'create' && action !== 'update') return
|
||||
if (type === 'logbook') return
|
||||
const key = `${ownerId}:${logbookId}`
|
||||
const entry = ownerNotifications.get(key) ?? { ownerId, logbookId, count: 0 }
|
||||
entry.count += 1
|
||||
ownerNotifications.set(key, entry)
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
const { action, type, payloadId, logbookId, data, updatedAt } = item
|
||||
@@ -103,15 +125,34 @@ router.post('/push', async (req: any, res) => {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse Payload parameters
|
||||
if (action === 'delete') {
|
||||
if (type === 'yacht') {
|
||||
await prisma.yachtPayload.deleteMany({ where: { logbookId } })
|
||||
} else if (type === 'deviation') {
|
||||
await prisma.deviationPayload.deleteMany({ where: { logbookId } })
|
||||
} else if (type === 'crew') {
|
||||
await prisma.crewPayload.deleteMany({ where: { logbookId, payloadId } })
|
||||
} else if (type === 'entry') {
|
||||
await prisma.entryPayload.deleteMany({ where: { logbookId, payloadId } })
|
||||
} else if (type === 'photo') {
|
||||
await prisma.photoPayload.deleteMany({ where: { logbookId, payloadId } })
|
||||
} else if (type === 'gpsTrack') {
|
||||
await prisma.gpsTrackPayload.deleteMany({ where: { logbookId, entryId: payloadId } })
|
||||
} else {
|
||||
results.push({ payloadId, status: 'error', error: `Unsupported delete type: ${type}` })
|
||||
continue
|
||||
}
|
||||
results.push({ payloadId, status: 'success' })
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse payload for create/update operations
|
||||
const parsed = JSON.parse(data)
|
||||
const encryptedData = parsed.encryptedData || parsed.ciphertext
|
||||
const { iv, tag } = parsed
|
||||
|
||||
if (type === 'yacht') {
|
||||
if (action === 'delete') {
|
||||
await prisma.yachtPayload.deleteMany({ where: { logbookId } })
|
||||
} else {
|
||||
{
|
||||
const existing = await prisma.yachtPayload.findUnique({ where: { logbookId } })
|
||||
if (existing && new Date(existing.updatedAt) > itemUpdatedAt) {
|
||||
results.push({ payloadId, status: 'conflict', reason: 'Server version is newer' })
|
||||
@@ -124,9 +165,7 @@ router.post('/push', async (req: any, res) => {
|
||||
})
|
||||
}
|
||||
} else if (type === 'deviation') {
|
||||
if (action === 'delete') {
|
||||
await prisma.deviationPayload.deleteMany({ where: { logbookId } })
|
||||
} else {
|
||||
{
|
||||
const existing = await prisma.deviationPayload.findUnique({ where: { logbookId } })
|
||||
if (existing && new Date(existing.updatedAt) > itemUpdatedAt) {
|
||||
results.push({ payloadId, status: 'conflict', reason: 'Server version is newer' })
|
||||
@@ -139,9 +178,7 @@ router.post('/push', async (req: any, res) => {
|
||||
})
|
||||
}
|
||||
} else if (type === 'crew') {
|
||||
if (action === 'delete') {
|
||||
await prisma.crewPayload.deleteMany({ where: { logbookId, payloadId } })
|
||||
} else {
|
||||
{
|
||||
const existing = await prisma.crewPayload.findUnique({
|
||||
where: { logbookId_payloadId: { logbookId, payloadId } }
|
||||
})
|
||||
@@ -156,9 +193,7 @@ router.post('/push', async (req: any, res) => {
|
||||
})
|
||||
}
|
||||
} else if (type === 'entry') {
|
||||
if (action === 'delete') {
|
||||
await prisma.entryPayload.deleteMany({ where: { logbookId, payloadId } })
|
||||
} else {
|
||||
{
|
||||
const existing = await prisma.entryPayload.findUnique({
|
||||
where: { logbookId_payloadId: { logbookId, payloadId } }
|
||||
})
|
||||
@@ -173,9 +208,7 @@ router.post('/push', async (req: any, res) => {
|
||||
})
|
||||
}
|
||||
} else if (type === 'photo') {
|
||||
if (action === 'delete') {
|
||||
await prisma.photoPayload.deleteMany({ where: { logbookId, payloadId } })
|
||||
} else {
|
||||
{
|
||||
const existing = await prisma.photoPayload.findUnique({
|
||||
where: { logbookId_payloadId: { logbookId, payloadId } }
|
||||
})
|
||||
@@ -191,9 +224,7 @@ router.post('/push', async (req: any, res) => {
|
||||
})
|
||||
}
|
||||
} else if (type === 'gpsTrack') {
|
||||
if (action === 'delete') {
|
||||
await prisma.gpsTrackPayload.deleteMany({ where: { logbookId, entryId: payloadId } })
|
||||
} else {
|
||||
{
|
||||
const existing = await prisma.gpsTrackPayload.findUnique({
|
||||
where: { entryId: payloadId }
|
||||
})
|
||||
@@ -209,6 +240,14 @@ router.post('/push', async (req: any, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
recordCollaboratorChange(
|
||||
logbook.userId,
|
||||
logbookId,
|
||||
isOwner,
|
||||
isCollaborator,
|
||||
action,
|
||||
type
|
||||
)
|
||||
results.push({ payloadId, status: 'success' })
|
||||
} catch (err: any) {
|
||||
console.error(`Error processing sync item ${payloadId}:`, err)
|
||||
@@ -216,6 +255,10 @@ router.post('/push', async (req: any, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
for (const { ownerId, logbookId, count } of ownerNotifications.values()) {
|
||||
void notifyOwnerOfCollaboratorChanges(logbookId, ownerId, req.userId, count)
|
||||
}
|
||||
|
||||
return res.json({ results })
|
||||
} catch (error: any) {
|
||||
console.error('Error during sync push:', error)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const requireUser = (req: any, res: any, next: any) => {
|
||||
const userId = req.headers['x-user-id']
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' })
|
||||
}
|
||||
req.userId = userId
|
||||
next()
|
||||
}
|
||||
|
||||
function resolveOwmApiKey(userProvidedKey: unknown): string | null {
|
||||
if (typeof userProvidedKey === 'string' && userProvidedKey.trim()) {
|
||||
return userProvidedKey.trim()
|
||||
}
|
||||
const fromEnv =
|
||||
process.env.OpenWeatherMapAPIKey?.trim() ||
|
||||
process.env.OPENWEATHERMAP_API_KEY?.trim()
|
||||
return fromEnv || null
|
||||
}
|
||||
|
||||
router.get('/current', requireUser, async (req: any, res) => {
|
||||
try {
|
||||
const { lat, lon, q } = req.query
|
||||
const apiKey = resolveOwmApiKey(req.headers['x-owm-api-key'])
|
||||
|
||||
if (!apiKey) {
|
||||
return res.status(503).json({
|
||||
error: 'No OpenWeatherMap API key configured (user settings or server environment)'
|
||||
})
|
||||
}
|
||||
|
||||
let url: URL
|
||||
if (lat && lon) {
|
||||
url = new URL('https://api.openweathermap.org/data/2.5/weather')
|
||||
url.searchParams.set('lat', String(lat))
|
||||
url.searchParams.set('lon', String(lon))
|
||||
} else if (q && typeof q === 'string' && q.trim()) {
|
||||
url = new URL('https://api.openweathermap.org/data/2.5/weather')
|
||||
url.searchParams.set('q', q.trim())
|
||||
} else {
|
||||
return res.status(400).json({ error: 'lat and lon, or q (location name) is required' })
|
||||
}
|
||||
|
||||
url.searchParams.set('appid', apiKey)
|
||||
url.searchParams.set('units', 'metric')
|
||||
|
||||
const owmRes = await fetch(url)
|
||||
const data = await owmRes.json()
|
||||
return res.status(owmRes.status).json(data)
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching OpenWeatherMap data:', error)
|
||||
return res.status(502).json({ error: error.message || 'Weather lookup failed' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,79 @@
|
||||
export interface FeedbackPayload {
|
||||
category: string
|
||||
message: string
|
||||
username?: string
|
||||
contactEmail?: string
|
||||
userId: string
|
||||
logbookId?: string
|
||||
logbookTitle?: string
|
||||
appVersion?: string
|
||||
pageUrl?: string
|
||||
}
|
||||
|
||||
function resolveNtfyConfig(): { server: string; topic: string; token?: string } | null {
|
||||
const server = (process.env.NTFY_SERVER || 'https://ntfy.sh').replace(/\/+$/, '')
|
||||
const topic = process.env.NTFY_TOPIC?.trim()
|
||||
const token = process.env.NTFY_TOKEN?.trim()
|
||||
|
||||
if (!topic) return null
|
||||
|
||||
return { server, topic, token: token || undefined }
|
||||
}
|
||||
|
||||
export function isNtfyConfigured(): boolean {
|
||||
return resolveNtfyConfig() !== null
|
||||
}
|
||||
|
||||
export async function sendFeedbackViaNtfy(payload: FeedbackPayload): Promise<void> {
|
||||
const config = resolveNtfyConfig()
|
||||
if (!config) {
|
||||
throw new Error('NTFY_TOPIC is not configured')
|
||||
}
|
||||
|
||||
const categoryLabel = payload.category.charAt(0).toUpperCase() + payload.category.slice(1)
|
||||
const title = `Kapteins Daagbok - ${categoryLabel}`
|
||||
|
||||
const lines = [
|
||||
payload.message,
|
||||
'',
|
||||
'---',
|
||||
`User: ${payload.username || '(unknown)'}`,
|
||||
`User ID: ${payload.userId}`
|
||||
]
|
||||
|
||||
if (payload.contactEmail) {
|
||||
lines.push(`Contact: ${payload.contactEmail}`)
|
||||
}
|
||||
|
||||
if (payload.logbookTitle || payload.logbookId) {
|
||||
lines.push(`Logbook: ${payload.logbookTitle || payload.logbookId}`)
|
||||
}
|
||||
if (payload.appVersion) {
|
||||
lines.push(`App version: ${payload.appVersion}`)
|
||||
}
|
||||
if (payload.pageUrl) {
|
||||
lines.push(`Page: ${payload.pageUrl}`)
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Title: title,
|
||||
Tags: 'speech_balloon,ship',
|
||||
'Content-Type': 'text/plain; charset=utf-8'
|
||||
}
|
||||
|
||||
if (config.token) {
|
||||
headers.Authorization = `Bearer ${config.token}`
|
||||
}
|
||||
|
||||
const url = `${config.server}/${encodeURIComponent(config.topic)}`
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: lines.join('\n')
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '')
|
||||
throw new Error(`Ntfy request failed (${res.status})${body ? `: ${body}` : ''}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import webpush from 'web-push'
|
||||
import { prisma } from '../db.js'
|
||||
|
||||
const THROTTLE_MS = 3 * 60 * 1000
|
||||
const lastSentByLogbook = new Map<string, number>()
|
||||
|
||||
let vapidConfigured = false
|
||||
|
||||
function ensureVapid(): boolean {
|
||||
if (vapidConfigured) return true
|
||||
const publicKey = process.env.VAPID_PUBLIC_KEY
|
||||
const privateKey = process.env.VAPID_PRIVATE_KEY
|
||||
const subject = process.env.VAPID_SUBJECT
|
||||
if (!publicKey || !privateKey || !subject) {
|
||||
return false
|
||||
}
|
||||
webpush.setVapidDetails(subject, publicKey, privateKey)
|
||||
vapidConfigured = true
|
||||
return true
|
||||
}
|
||||
|
||||
function isThrottled(ownerUserId: string, logbookId: string): boolean {
|
||||
const key = `${ownerUserId}:${logbookId}`
|
||||
const last = lastSentByLogbook.get(key) ?? 0
|
||||
return Date.now() - last < THROTTLE_MS
|
||||
}
|
||||
|
||||
function markSent(ownerUserId: string, logbookId: string): void {
|
||||
lastSentByLogbook.set(`${ownerUserId}:${logbookId}`, Date.now())
|
||||
}
|
||||
|
||||
function notificationCopy(locale: string | null | undefined, changeCount: number): { title: string; body: string } {
|
||||
const isDe = !locale || locale.startsWith('de')
|
||||
const title = 'Kapteins Daagbok'
|
||||
if (isDe) {
|
||||
const body =
|
||||
changeCount > 1
|
||||
? `${changeCount} neue Änderungen in einem Ihrer Logbücher.`
|
||||
: 'Neue Änderung in einem Ihrer Logbücher.'
|
||||
return { title, body }
|
||||
}
|
||||
const body =
|
||||
changeCount > 1
|
||||
? `${changeCount} new changes in one of your logbooks.`
|
||||
: 'New change in one of your logbooks.'
|
||||
return { title, body }
|
||||
}
|
||||
|
||||
export async function notifyOwnerOfCollaboratorChanges(
|
||||
logbookId: string,
|
||||
ownerUserId: string,
|
||||
_actorUserId: string,
|
||||
changeCount: number
|
||||
): Promise<void> {
|
||||
if (!ensureVapid() || changeCount < 1) return
|
||||
if (isThrottled(ownerUserId, logbookId)) return
|
||||
|
||||
const prefs = await prisma.userNotificationPrefs.findUnique({
|
||||
where: { userId: ownerUserId }
|
||||
})
|
||||
if (!prefs?.collaboratorChangesEnabled) return
|
||||
|
||||
const subscriptions = await prisma.pushSubscription.findMany({
|
||||
where: { userId: ownerUserId }
|
||||
})
|
||||
if (subscriptions.length === 0) return
|
||||
|
||||
markSent(ownerUserId, logbookId)
|
||||
|
||||
const payloadBase = {
|
||||
tag: `logbook-change-${logbookId}`,
|
||||
renotify: false,
|
||||
data: {
|
||||
url: `/?logbook=${encodeURIComponent(logbookId)}`,
|
||||
logbookId,
|
||||
changeCount
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.allSettled(
|
||||
subscriptions.map(async (sub) => {
|
||||
const { title, body } = notificationCopy(sub.locale, changeCount)
|
||||
const payload = JSON.stringify({ title, body, ...payloadBase })
|
||||
try {
|
||||
await webpush.sendNotification(
|
||||
{
|
||||
endpoint: sub.endpoint,
|
||||
keys: { p256dh: sub.p256dh, auth: sub.auth }
|
||||
},
|
||||
payload
|
||||
)
|
||||
} catch (err: unknown) {
|
||||
const statusCode =
|
||||
err && typeof err === 'object' && 'statusCode' in err
|
||||
? (err as { statusCode: number }).statusCode
|
||||
: undefined
|
||||
if (statusCode === 404 || statusCode === 410) {
|
||||
await prisma.pushSubscription.delete({ where: { id: sub.id } }).catch(() => {})
|
||||
} else {
|
||||
console.warn('[push] Failed to send notification:', err)
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user