Compare commits
63 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 |
@@ -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)
|
||||
+22
-2
@@ -1,17 +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" />
|
||||
<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</title>
|
||||
<title>Kapteins Daagbok – Kostenloses digitales Yacht-Logbuch (werbefrei)</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
+619
-4
@@ -388,6 +388,106 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
max-height: min(90vh, 820px);
|
||||
}
|
||||
|
||||
.feedback-modal {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.feedback-modal__close {
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.feedback-status {
|
||||
text-align: center;
|
||||
padding: 8px 0 4px;
|
||||
}
|
||||
|
||||
.feedback-status p {
|
||||
margin: 12px 0 0;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.feedback-status--success {
|
||||
color: #4ade80;
|
||||
padding: 24px 8px 32px;
|
||||
}
|
||||
|
||||
.feedback-status--success p {
|
||||
color: var(--app-text-heading, #f1f5f9);
|
||||
}
|
||||
|
||||
.feedback-status--error {
|
||||
color: var(--app-error-text, #fda4af);
|
||||
background: var(--app-error-bg, rgba(244, 63, 94, 0.08));
|
||||
border: 1px solid var(--app-error-border, #f43f5e);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.feedback-status--error p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.feedback-modal .auth-actions {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.feedback-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.feedback-form__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.feedback-form__field > span {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--app-text-heading, #f1f5f9);
|
||||
}
|
||||
|
||||
.feedback-form__field select,
|
||||
.feedback-form__field input,
|
||||
.feedback-form__field textarea {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--app-input-border, rgba(148, 163, 184, 0.25));
|
||||
background: var(--app-input-bg, rgba(15, 23, 42, 0.6));
|
||||
color: var(--app-text, #e2e8f0);
|
||||
font: inherit;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.feedback-form__field select:focus,
|
||||
.feedback-form__field input:focus,
|
||||
.feedback-form__field textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--app-accent, #38bdf8);
|
||||
}
|
||||
|
||||
.feedback-form__actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.feedback-form__actions .btn {
|
||||
width: auto;
|
||||
min-width: 100px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.registration-disclaimer__intro {
|
||||
margin: 0 0 16px;
|
||||
font-size: 14px;
|
||||
@@ -619,9 +719,18 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
font-size: 13px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
background: var(--app-btn-secondary-bg);
|
||||
border: 1px solid var(--app-btn-secondary-border);
|
||||
color: var(--app-btn-secondary-text);
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
color: var(--app-text-muted);
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.skipper-badge__name {
|
||||
max-width: 140px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
@@ -839,6 +948,42 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
background: var(--app-surface-hover);
|
||||
}
|
||||
|
||||
.logbook-card--shared {
|
||||
border-left: 3px solid #38bdf8;
|
||||
}
|
||||
|
||||
.logbook-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
.logbook-section-header h3 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--app-text-heading);
|
||||
}
|
||||
|
||||
.logbook-section-hint {
|
||||
margin: 0 0 14px;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
color: var(--app-text-muted);
|
||||
max-width: 52rem;
|
||||
}
|
||||
|
||||
.card-title-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-title-row h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
background: var(--app-accent-bg);
|
||||
color: var(--app-accent-light);
|
||||
@@ -895,6 +1040,44 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
color: var(--app-text-subtle);
|
||||
}
|
||||
|
||||
.entry-sign-badge {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.entry-sign-badge--skipper.valid {
|
||||
color: #86efac;
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
border: 1px solid rgba(34, 197, 94, 0.25);
|
||||
padding: 3px 7px;
|
||||
}
|
||||
|
||||
.entry-sign-badge--skipper.invalid {
|
||||
color: #fde68a;
|
||||
background: rgba(251, 191, 36, 0.12);
|
||||
border: 1px solid rgba(251, 191, 36, 0.28);
|
||||
}
|
||||
|
||||
.entry-sign-badge__sr-label {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -999,6 +1182,13 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
color: var(--app-text-heading);
|
||||
}
|
||||
|
||||
.app-title-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.app-title-area .app-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--app-text-muted);
|
||||
@@ -1349,6 +1539,18 @@ html.scheme-dark .themed-select-option.is-selected {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.events-actions-td {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.events-actions-td .btn-icon {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.events-actions-td .btn-icon:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.events-table tbody tr:hover {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
@@ -2109,6 +2311,325 @@ html.theme-cupertino .events-scroll-container {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
/* --- Statistics dashboard --- */
|
||||
.stats-subtitle {
|
||||
margin: 4px 0 0;
|
||||
font-size: 14px;
|
||||
color: var(--app-text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.stats-scope-toggle {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.stats-scope-toggle .btn {
|
||||
width: auto;
|
||||
padding: 10px 18px;
|
||||
}
|
||||
|
||||
.stats-kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.stats-kpi-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-radius: var(--app-radius-card);
|
||||
border: 1px solid var(--app-border-subtle);
|
||||
}
|
||||
|
||||
.stats-kpi-icon {
|
||||
color: var(--app-accent-light);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.stats-kpi-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--app-text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stats-kpi-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: var(--app-text-primary);
|
||||
}
|
||||
|
||||
.stats-kpi-unit {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--app-text-muted);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.stats-section-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stats-section-sub {
|
||||
margin: 0 0 16px;
|
||||
font-size: 13px;
|
||||
color: var(--app-text-muted);
|
||||
}
|
||||
|
||||
.stats-route-chain {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
color: var(--app-text-primary);
|
||||
}
|
||||
|
||||
.stats-route-arrow {
|
||||
color: var(--app-accent-light);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stats-multi-track-map {
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.stats-track-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 18px;
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--app-text-muted);
|
||||
}
|
||||
|
||||
.stats-track-legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.stats-track-legend-swatch {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stats-bar-chart {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
min-height: 180px;
|
||||
padding-top: 8px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.stats-bar-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 52px;
|
||||
flex: 1 0 52px;
|
||||
}
|
||||
|
||||
.stats-bar-column--grouped {
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
.stats-bar-value {
|
||||
font-size: 10px;
|
||||
color: var(--app-text-muted);
|
||||
min-height: 14px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stats-bar-track {
|
||||
width: 100%;
|
||||
max-width: 36px;
|
||||
height: 120px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
background: var(--app-surface-muted);
|
||||
border-radius: 6px 6px 2px 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stats-bar-track--short {
|
||||
max-width: 14px;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.stats-bar {
|
||||
width: 100%;
|
||||
border-radius: 4px 4px 0 0;
|
||||
min-height: 2px;
|
||||
transition: height 0.2s ease;
|
||||
}
|
||||
|
||||
.stats-bar--distance {
|
||||
background: linear-gradient(180deg, var(--app-accent-light), var(--app-accent));
|
||||
}
|
||||
|
||||
.stats-bar--fuel {
|
||||
background: linear-gradient(180deg, #fbbf24, #d97706);
|
||||
}
|
||||
|
||||
.stats-bar--water {
|
||||
background: linear-gradient(180deg, #38bdf8, #0284c7);
|
||||
}
|
||||
|
||||
.stats-bar-label {
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--app-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stats-bar-sublabel {
|
||||
font-size: 10px;
|
||||
color: var(--app-text-muted);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.stats-bar-group {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: flex-end;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.stats-consumption-chart {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.stats-consumption-chart .stats-bar-column--grouped {
|
||||
display: inline-flex;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
.stats-consumption-chart {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.stats-consumption-chart .stats-bar-column--grouped {
|
||||
display: inline-flex;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.stats-consumption-legend {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-top: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--app-text-muted);
|
||||
}
|
||||
|
||||
.stats-consumption-legend span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.stats-legend-swatch {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.stats-propulsion-bar {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
background: var(--app-surface-muted);
|
||||
}
|
||||
|
||||
.stats-propulsion-segment--sail {
|
||||
background: var(--app-accent);
|
||||
}
|
||||
|
||||
.stats-propulsion-segment--motor {
|
||||
background: #d97706;
|
||||
}
|
||||
|
||||
.stats-propulsion-segment--unknown {
|
||||
background: var(--app-text-muted);
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.stats-propulsion-labels {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 20px;
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--app-text-primary);
|
||||
}
|
||||
|
||||
.stats-hint {
|
||||
margin: 12px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--app-text-muted);
|
||||
}
|
||||
|
||||
.stats-account-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.stats-account-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.stats-account-table th,
|
||||
.stats-account-table td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--app-border-subtle);
|
||||
}
|
||||
|
||||
.stats-account-table th {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--app-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.stats-kpi-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.stats-kpi-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stats-kpi-value {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.signature-grid {
|
||||
align-items: start;
|
||||
}
|
||||
@@ -2634,10 +3155,14 @@ html.theme-cupertino .events-scroll-container {
|
||||
|
||||
.app-version-footer__copyright {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.app-version-footer__copyright a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.app-version-footer__copyright:hover {
|
||||
.app-version-footer__copyright a:hover {
|
||||
color: #e2e8f0;
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -2656,6 +3181,96 @@ html.theme-cupertino .events-scroll-container {
|
||||
border: 1px solid rgba(251, 191, 36, 0.25);
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.role-badge--owner {
|
||||
color: #86efac;
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
border: 1px solid rgba(34, 197, 94, 0.25);
|
||||
}
|
||||
|
||||
.role-badge--crew {
|
||||
color: #7dd3fc;
|
||||
background: rgba(56, 189, 248, 0.12);
|
||||
border: 1px solid rgba(56, 189, 248, 0.28);
|
||||
}
|
||||
|
||||
.role-badge--read {
|
||||
color: #cbd5e1;
|
||||
background: rgba(148, 163, 184, 0.12);
|
||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||
}
|
||||
|
||||
.backup-panel .backup-section {
|
||||
margin-bottom: 28px;
|
||||
padding-bottom: 24px;
|
||||
border-bottom: 1px solid var(--app-border-subtle);
|
||||
}
|
||||
|
||||
.backup-panel .backup-section--import {
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.backup-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 0 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--app-text-heading);
|
||||
}
|
||||
|
||||
.backup-section-desc {
|
||||
font-size: 13px;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.backup-actions-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.backup-preview {
|
||||
margin-top: 16px;
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--app-radius-card);
|
||||
border: 1px solid var(--app-border-subtle);
|
||||
}
|
||||
|
||||
.backup-preview-title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--app-text-heading);
|
||||
}
|
||||
|
||||
.backup-preview-stats {
|
||||
margin: 0 0 8px;
|
||||
padding-left: 18px;
|
||||
font-size: 13px;
|
||||
color: var(--app-text-muted);
|
||||
}
|
||||
|
||||
.backup-preview-date {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.app-tour-root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
||||
+181
-11
@@ -1,4 +1,4 @@
|
||||
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'
|
||||
@@ -8,6 +8,7 @@ import CrewForm from './components/CrewForm.tsx'
|
||||
// Compass Deviation Table — für Freizeit-Skipper vorerst deaktiviert (Komponente bleibt erhalten)
|
||||
// import DeviationForm from './components/DeviationForm.tsx'
|
||||
import LogEntriesList from './components/LogEntriesList.tsx'
|
||||
import StatsDashboard from './components/StatsDashboard.tsx'
|
||||
import SettingsForm from './components/SettingsForm.tsx'
|
||||
import InvitationAcceptance from './components/InvitationAcceptance.tsx'
|
||||
import AppTourOverlay from './components/AppTourOverlay.tsx'
|
||||
@@ -22,18 +23,27 @@ import {
|
||||
} from './services/appearance.js'
|
||||
import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks, subscribeToSyncState } from './services/sync.js'
|
||||
import ReadOnlyViewer from './components/ReadOnlyViewer.tsx'
|
||||
import DemoViewer from './components/DemoViewer.tsx'
|
||||
import PwaInstallPrompt from './components/PwaInstallPrompt.tsx'
|
||||
import PwaUpdatePrompt from './components/PwaUpdatePrompt.tsx'
|
||||
import AppFooter from './components/AppFooter.tsx'
|
||||
import LogbookRoleBadge from './components/LogbookRoleBadge.tsx'
|
||||
import { db } from './services/db.js'
|
||||
import { getLogbookAccess } from './services/logbookAccess.js'
|
||||
import type { LogbookAccessRole } from './services/logbook.js'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, Languages } 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, i18n } = useTranslation()
|
||||
@@ -53,11 +63,42 @@ 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 activeLogbookRecord = useLiveQuery(
|
||||
() => (activeLogbookId ? db.logbooks.get(activeLogbookId) : undefined),
|
||||
[activeLogbookId]
|
||||
)
|
||||
|
||||
const [activeAccessRole, setActiveAccessRole] = useState<LogbookAccessRole>('OWNER')
|
||||
|
||||
useEffect(() => {
|
||||
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())
|
||||
@@ -102,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')
|
||||
@@ -130,6 +199,19 @@ function App() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
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,
|
||||
@@ -152,9 +234,53 @@ function App() {
|
||||
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()
|
||||
@@ -164,6 +290,7 @@ function App() {
|
||||
setDemoHighlightEntryId(demo.firstEntryId)
|
||||
}
|
||||
requestStartAfterLogin()
|
||||
consumePendingPushLogbook()
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -176,6 +303,7 @@ function App() {
|
||||
setActiveLogbookId(savedLogbookId)
|
||||
setActiveLogbookTitle(savedLogbookTitle)
|
||||
}
|
||||
consumePendingPushLogbook()
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
@@ -202,6 +330,19 @@ function App() {
|
||||
i18n.changeLanguage(nextLang)
|
||||
}
|
||||
|
||||
const handleExitDemo = () => {
|
||||
window.history.replaceState({}, document.title, '/')
|
||||
syncRouteFromLocation()
|
||||
}
|
||||
|
||||
if (isDemoMode) {
|
||||
return (
|
||||
<div style={{ display: 'contents' }}>
|
||||
<DemoViewer onExit={handleExitDemo} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isViewerMode) {
|
||||
return (
|
||||
<div style={{ display: 'contents' }}>
|
||||
@@ -234,7 +375,7 @@ function App() {
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="auth-screen">
|
||||
<AuthOnboarding onAuthenticated={handleAuthenticated} />
|
||||
<AuthOnboarding onAuthenticated={handleAuthenticated} onOpenDemo={openDemo} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -266,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>
|
||||
|
||||
@@ -290,6 +440,11 @@ function App() {
|
||||
|
||||
<DisclaimerHeaderButton />
|
||||
|
||||
<FeedbackHeaderButton
|
||||
logbookId={activeLogbookId}
|
||||
logbookTitle={activeLogbookTitle}
|
||||
/>
|
||||
|
||||
<button className="btn-icon logout" onClick={handleLogout} title={t('dashboard.logout')}>
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
@@ -337,6 +492,14 @@ function App() {
|
||||
</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' : ''}`}
|
||||
onClick={() => setActiveTab('settings')}
|
||||
@@ -365,6 +528,10 @@ 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} />
|
||||
@@ -372,7 +539,10 @@ function App() {
|
||||
*/}
|
||||
|
||||
{activeTab === 'settings' && (
|
||||
<SettingsForm logbookId={activeLogbookId} />
|
||||
<SettingsForm
|
||||
logbookId={activeLogbookId}
|
||||
onLogbookRestored={selectLogbook}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
|
||||
const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'
|
||||
|
||||
export default function AppFooter() {
|
||||
@@ -7,9 +9,15 @@ export default function AppFooter() {
|
||||
<span className="app-version-footer__sep" aria-hidden="true">
|
||||
·
|
||||
</span>
|
||||
<a className="app-version-footer__copyright" href="mailto:elpatron+kd@mailbox.org">
|
||||
© 2026 Markus F.J. Busche
|
||||
</a>
|
||||
<span className="app-version-footer__copyright">
|
||||
© 2026 KnorrLabs/
|
||||
<a
|
||||
href="mailto:elpatron+kd@mailbox.org"
|
||||
onClick={() => trackPlausibleEvent(PlausibleEvents.FOOTER_LINK_CLICKED)}
|
||||
>
|
||||
Markus F.J. Busche
|
||||
</a>
|
||||
</span>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export default function AppTourOverlay() {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
isActive,
|
||||
isDemoTour,
|
||||
currentStepId,
|
||||
currentStepIndex,
|
||||
totalSteps,
|
||||
@@ -104,7 +105,7 @@ export default function AppTourOverlay() {
|
||||
|
||||
if (!isActive || !currentStepId) return null
|
||||
|
||||
const { title, body } = getTourStepCopy(currentStepId, t)
|
||||
const { title, body } = getTourStepCopy(currentStepId, t, { demoMode: isDemoTour })
|
||||
const centered = isCenteredTourStep(currentStepId)
|
||||
|
||||
const tooltipStyle = centered
|
||||
|
||||
@@ -16,9 +16,10 @@ 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)
|
||||
@@ -523,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">
|
||||
|
||||
@@ -455,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')
|
||||
}
|
||||
@@ -662,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,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,6 +10,7 @@ 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'
|
||||
@@ -182,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) {
|
||||
@@ -190,7 +194,8 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
||||
encryptedTitle: rawEncryptedTitle,
|
||||
updatedAt: new Date().toISOString(),
|
||||
isSynced: 1,
|
||||
isShared: 1
|
||||
isShared: 1,
|
||||
collaborationRole
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,9 @@ 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 {
|
||||
carryOverFromPreviousDay,
|
||||
@@ -41,6 +43,7 @@ interface DecryptedEntryItem {
|
||||
departure: string
|
||||
destination: string
|
||||
updatedAt: string
|
||||
skipperSignStatus: SkipperSignStatus
|
||||
}
|
||||
|
||||
export default function LogEntriesList({
|
||||
@@ -79,14 +82,18 @@ export default function LogEntriesList({
|
||||
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()
|
||||
@@ -114,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>)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -411,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,24 +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,
|
||||
@@ -34,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
|
||||
@@ -45,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,
|
||||
@@ -76,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('')
|
||||
@@ -137,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)
|
||||
@@ -145,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)
|
||||
@@ -167,7 +208,7 @@ export default function LogEntryEditor({
|
||||
}
|
||||
}
|
||||
|
||||
const buildPayloadForSigning = useCallback(() => {
|
||||
const buildPayloadForSigning = useCallback((eventsOverride?: LogEvent[]) => {
|
||||
return buildLogEntryPayload({
|
||||
date,
|
||||
dayOfTravel,
|
||||
@@ -188,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,
|
||||
@@ -198,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)
|
||||
@@ -250,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(
|
||||
@@ -353,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 || '')
|
||||
@@ -379,6 +484,7 @@ export default function LogEntryEditor({
|
||||
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
|
||||
loadTrackStatsFromEntry(preloadedEntry)
|
||||
setEvents(preloadedEntry.events || [])
|
||||
setSavedFingerprint(fingerprintFromStoredEntry(preloadedEntry))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -411,6 +517,7 @@ export default function LogEntryEditor({
|
||||
setSignCrew(normalizeSignature(decrypted.signCrew) || '')
|
||||
loadTrackStatsFromEntry(decrypted)
|
||||
setEvents(decrypted.events || [])
|
||||
setSavedFingerprint(fingerprintFromStoredEntry(decrypted))
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
@@ -426,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 {
|
||||
@@ -529,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.')
|
||||
}
|
||||
}
|
||||
@@ -585,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
|
||||
@@ -630,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 {
|
||||
@@ -680,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('')
|
||||
@@ -723,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 () => {
|
||||
@@ -746,45 +935,13 @@ 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)
|
||||
@@ -792,8 +949,6 @@ export default function LogEntryEditor({
|
||||
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.')
|
||||
@@ -1067,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>
|
||||
@@ -1083,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">
|
||||
@@ -1317,16 +1488,30 @@ 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>
|
||||
@@ -1367,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')}</>
|
||||
)}
|
||||
@@ -1483,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,12 +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
|
||||
@@ -82,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 {
|
||||
@@ -106,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 */}
|
||||
@@ -142,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 */}
|
||||
@@ -154,6 +222,8 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
||||
|
||||
<DisclaimerHeaderButton />
|
||||
|
||||
<FeedbackHeaderButton />
|
||||
|
||||
{/* Logout */}
|
||||
<button className="btn-icon logout" onClick={handleLogout} title={t('dashboard.logout')}>
|
||||
<LogOut size={18} />
|
||||
@@ -201,42 +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>
|
||||
{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>
|
||||
))}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -2,8 +2,10 @@ import React, { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Settings as SettingsIcon, Save, Check, Users, Trash2, Copy, Link as LinkIcon, Compass } from 'lucide-react'
|
||||
import { 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'
|
||||
@@ -12,6 +14,7 @@ import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
|
||||
interface SettingsFormProps {
|
||||
logbookId?: string | null
|
||||
onLogbookRestored?: (logbookId: string, title: string) => void
|
||||
}
|
||||
|
||||
interface Collaborator {
|
||||
@@ -29,7 +32,7 @@ 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()
|
||||
@@ -295,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">
|
||||
@@ -454,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,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>
|
||||
)
|
||||
}
|
||||
@@ -11,12 +11,13 @@ import {
|
||||
import {
|
||||
clearTourCompleted,
|
||||
isTourCompleted,
|
||||
markTourCompleted
|
||||
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' | 'settings'
|
||||
export type AppTab = 'vessel' | 'crew' | 'logs' | 'stats' | 'settings'
|
||||
|
||||
export type TourStepId =
|
||||
| 'welcome'
|
||||
@@ -33,18 +34,24 @@ interface TourNavigation {
|
||||
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 }) => void
|
||||
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
|
||||
}
|
||||
|
||||
@@ -74,10 +81,17 @@ 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
|
||||
@@ -86,7 +100,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
||||
nav.setActiveTab('logs')
|
||||
}
|
||||
if (stepId === 'entry_open' || stepId === 'entry_track') {
|
||||
const firstEntryId = getStoredDemoFirstEntryId()
|
||||
const firstEntryId = resolveFirstEntryId()
|
||||
if (firstEntryId) nav.setSelectedEntryId(firstEntryId)
|
||||
}
|
||||
if (stepId === 'nav_vessel') {
|
||||
@@ -97,7 +111,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
||||
nav.setSelectedEntryId(null)
|
||||
nav.setActiveTab('crew')
|
||||
}
|
||||
}, [])
|
||||
}, [resolveFirstEntryId])
|
||||
|
||||
const scrollToCurrentTarget = useCallback((stepId: TourStepId | null) => {
|
||||
if (!stepId) return
|
||||
@@ -109,24 +123,32 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
||||
})
|
||||
}, [])
|
||||
|
||||
const startTour = useCallback((options?: { force?: boolean }) => {
|
||||
const userId = localStorage.getItem('active_userid')
|
||||
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 = localStorage.getItem('active_userid')
|
||||
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)
|
||||
trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_COMPLETED, tourProps)
|
||||
} else {
|
||||
const step = STEP_ORDER[stepIndexAtDismiss] ?? 'welcome'
|
||||
trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_SKIPPED, { step })
|
||||
trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_SKIPPED, { step, ...tourProps })
|
||||
}
|
||||
|
||||
tourModeRef.current = { demoMode: false }
|
||||
setIsDemoTour(false)
|
||||
setIsActive(false)
|
||||
setStepIndex(0)
|
||||
}, [])
|
||||
@@ -170,6 +192,10 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
||||
navigationRef.current = navigation
|
||||
}, [])
|
||||
|
||||
const registerDemoTourContext = useCallback((context: DemoTourContext | null) => {
|
||||
demoContextRef.current = context
|
||||
}, [])
|
||||
|
||||
const requestStartAfterLogin = useCallback(() => {
|
||||
setPendingAfterLogin(true)
|
||||
}, [])
|
||||
@@ -191,6 +217,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
||||
const value = useMemo<AppTourContextValue>(
|
||||
() => ({
|
||||
isActive,
|
||||
isDemoTour,
|
||||
currentStepId,
|
||||
currentStepIndex: stepIndex,
|
||||
totalSteps: STEP_ORDER.length,
|
||||
@@ -201,13 +228,16 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
||||
prevStep,
|
||||
skipTour,
|
||||
registerNavigation,
|
||||
registerDemoTourContext,
|
||||
requestStartAfterLogin
|
||||
}),
|
||||
[
|
||||
currentStepId,
|
||||
isActive,
|
||||
isDemoTour,
|
||||
nextStep,
|
||||
prevStep,
|
||||
registerDemoTourContext,
|
||||
registerNavigation,
|
||||
requestStartAfterLogin,
|
||||
restartTour,
|
||||
@@ -231,8 +261,15 @@ export function useAppTour(): AppTourContextValue {
|
||||
|
||||
export function getTourStepCopy(
|
||||
stepId: TourStepId,
|
||||
t: (key: string) => string
|
||||
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`)
|
||||
|
||||
@@ -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",
|
||||
@@ -113,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)",
|
||||
@@ -140,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",
|
||||
@@ -230,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",
|
||||
@@ -278,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.",
|
||||
@@ -311,7 +334,49 @@
|
||||
"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"
|
||||
"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",
|
||||
@@ -333,9 +398,65 @@
|
||||
"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"
|
||||
"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",
|
||||
@@ -348,6 +469,10 @@
|
||||
"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."
|
||||
|
||||
@@ -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",
|
||||
@@ -113,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)",
|
||||
@@ -140,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",
|
||||
@@ -230,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",
|
||||
@@ -278,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}}.",
|
||||
@@ -311,7 +334,49 @@
|
||||
"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"
|
||||
"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",
|
||||
@@ -333,9 +398,65 @@
|
||||
"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"
|
||||
"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",
|
||||
@@ -348,6 +469,10 @@
|
||||
"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."
|
||||
|
||||
@@ -16,7 +16,14 @@ export const PlausibleEvents = {
|
||||
INVITE_ACCEPTED: 'Invite Accepted',
|
||||
PDF_EXPORTED: 'PDF Exported',
|
||||
CSV_EXPORTED: 'CSV Exported',
|
||||
CSV_SHARED: 'CSV Shared'
|
||||
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]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export const PUBLIC_DEMO_TOUR_USER_ID = '__public_demo__'
|
||||
|
||||
export function getTourCompletedKey(userId: string): string {
|
||||
return `app_tour_completed_${userId}`
|
||||
}
|
||||
@@ -14,3 +16,10 @@ export function markTourCompleted(userId: string): void {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface LocalLogbook {
|
||||
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 {
|
||||
|
||||
@@ -3,14 +3,13 @@ import { db } from './db.js'
|
||||
import { getActiveMasterKey } from './auth.js'
|
||||
import { getLogbookKey } from './logbookKeys.js'
|
||||
import { encryptJson } from './crypto.js'
|
||||
import { parseTrackFile } from './trackUpload.js'
|
||||
import { syncLogbook } from './sync.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'
|
||||
import {
|
||||
buildDemoCrewRecords,
|
||||
buildDemoEntryPayloads,
|
||||
buildDemoYachtData
|
||||
} from './demoLogbookData.js'
|
||||
|
||||
export const SEED_DEMO_FLAG = 'seed_demo_logbook'
|
||||
|
||||
@@ -22,120 +21,6 @@ export function getDemoFirstEntryStorageKey(userId: string): string {
|
||||
return `demo_first_entry_id_${userId}`
|
||||
}
|
||||
|
||||
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>>
|
||||
}
|
||||
|
||||
function buildDemoDays(): DemoDaySpec[] {
|
||||
const isDe = i18n.language.startsWith('de')
|
||||
return [
|
||||
{
|
||||
date: '2026-05-29',
|
||||
dayOfTravel: '1',
|
||||
departure: isDe ? 'Kiel' : 'Kiel',
|
||||
destination: isDe ? 'Laboe' : '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: isDe ? 'NW' : '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: isDe ? 'Schleimünde' : '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'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
async function putEncryptedRecord(
|
||||
logbookId: string,
|
||||
key: ArrayBuffer,
|
||||
@@ -194,44 +79,12 @@ async function putEncryptedRecord(
|
||||
}
|
||||
|
||||
async function seedYachtAndCrew(logbookId: string, key: ArrayBuffer, now: string): Promise<void> {
|
||||
const isDe = i18n.language.startsWith('de')
|
||||
const yachtData = {
|
||||
name: isDe ? 'Seeadler' : 'Seeadler',
|
||||
vesselType: isDe ? 'Segelyacht' : 'Sailing yacht',
|
||||
lengthM: 12.5,
|
||||
draftM: 1.9,
|
||||
airDraftM: 18,
|
||||
homePort: 'Kiel',
|
||||
charterCompany: '',
|
||||
owner: isDe ? 'Demo Skipper' : 'Demo Skipper',
|
||||
registrationNumber: 'D-KI 1234',
|
||||
callSign: 'DA1234',
|
||||
atis: '',
|
||||
mmsi: '',
|
||||
sails: isDe
|
||||
? ['Großsegel', 'Genua', 'Spinnaker']
|
||||
: ['Mainsail', 'Genoa', 'Spinnaker'],
|
||||
photo: null
|
||||
}
|
||||
|
||||
const yachtData = buildDemoYachtData()
|
||||
await putEncryptedRecord(logbookId, key, 'yacht', logbookId, yachtData, now)
|
||||
|
||||
const crewId = crypto.randomUUID()
|
||||
const crewData = {
|
||||
name: isDe ? 'Anna Müller' : '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
|
||||
for (const crew of buildDemoCrewRecords()) {
|
||||
await putEncryptedRecord(logbookId, key, 'crew', crew.payloadId, crew.data, now)
|
||||
}
|
||||
|
||||
await putEncryptedRecord(logbookId, key, 'crew', crewId, crewData, now)
|
||||
}
|
||||
|
||||
export interface DemoSeedResult {
|
||||
@@ -273,42 +126,12 @@ export async function seedDemoLogbookIfNeeded(): Promise<DemoSeedResult | null>
|
||||
const now = new Date().toISOString()
|
||||
await seedYachtAndCrew(logbookId, key, now)
|
||||
|
||||
const days = buildDemoDays()
|
||||
const entryPayloads = buildDemoEntryPayloads()
|
||||
let firstEntryId = ''
|
||||
|
||||
for (const day of days) {
|
||||
const entryId = crypto.randomUUID()
|
||||
for (const { entryId, entryPayload, trackData } of entryPayloads) {
|
||||
if (!firstEntryId) firstEntryId = entryId
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
await putEncryptedRecord(logbookId, key, 'entry', entryId, entryPayload, now)
|
||||
|
||||
const trackData = {
|
||||
waypoints,
|
||||
gpxContent: day.gpx,
|
||||
filename: day.filename,
|
||||
fileType: 'gpx'
|
||||
}
|
||||
await putEncryptedRecord(logbookId, key, 'gpsTrack', entryId, trackData, now)
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,31 @@ 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
|
||||
}
|
||||
|
||||
@@ -101,14 +120,20 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
|
||||
|
||||
// Update Dexie database cache
|
||||
const localById = new Map(localLogbooksArray.map((lb) => [lb.id, lb]))
|
||||
const localLogbooks: LocalLogbook[] = serverLogbooks.map((lb: any) => ({
|
||||
id: lb.id,
|
||||
encryptedTitle: lb.encryptedTitle,
|
||||
updatedAt: lb.updatedAt || new Date().toISOString(),
|
||||
isSynced: 1,
|
||||
isShared: lb.userId !== userId ? 1 : 0,
|
||||
isDemo: localById.get(lb.id)?.isDemo
|
||||
}))
|
||||
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)
|
||||
@@ -131,6 +156,9 @@ export async function fetchLogbooks(): Promise<DecryptedLogbook[]> {
|
||||
updatedAt: lb.updatedAt,
|
||||
isSynced: lb.isSynced === 1,
|
||||
isShared: lb.isShared === 1,
|
||||
accessRole: lb.isShared === 1
|
||||
? parseCollaborationRole(lb.collaborationRole, `cached logbook ${lb.id}`)
|
||||
: 'OWNER',
|
||||
isDemo: lb.isDemo === 1
|
||||
})
|
||||
}
|
||||
@@ -207,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) {
|
||||
@@ -238,7 +267,8 @@ export async function createLogbook(title: string): Promise<DecryptedLogbook> {
|
||||
title,
|
||||
updatedAt: now,
|
||||
isSynced: false,
|
||||
isShared: false
|
||||
isShared: false,
|
||||
accessRole: 'OWNER'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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
+9
@@ -1,5 +1,14 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/react" />
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_VAPID_PUBLIC_KEY?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
declare module '*?raw' {
|
||||
const content: string
|
||||
|
||||
@@ -38,16 +38,18 @@ export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
strategies: 'injectManifest',
|
||||
srcDir: 'src',
|
||||
filename: 'sw.ts',
|
||||
registerType: 'prompt',
|
||||
includeAssets: ['favicon.ico', 'logo.png'],
|
||||
workbox: {
|
||||
cleanupOutdatedCaches: true,
|
||||
injectManifest: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2,webmanifest}']
|
||||
},
|
||||
manifest: {
|
||||
name: 'Kapteins Daagbok',
|
||||
short_name: 'Daagbok',
|
||||
description: 'Digital maritime ship logbook with E2E encryption and Passkeys',
|
||||
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.
@@ -24,13 +24,20 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
|
||||
| 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`) | — |
|
||||
| Onboarding Tour Skipped | Tour vorzeitig beendet (Skip, Escape, Backdrop, `stopTour`) | `step`: Tour-Schritt-ID (z.B. `welcome`, `entry_track`) |
|
||||
| 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
|
||||
|
||||
@@ -46,6 +53,7 @@ Empfohlene Goal-Ketten für Auswertung:
|
||||
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
|
||||
|
||||
|
||||
@@ -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()
|
||||
+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
|
||||
+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