Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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 |
|
||||
+7
-1
@@ -4,4 +4,10 @@ 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
|
||||
@@ -13,15 +13,18 @@ Alle sensiblen Inhalte werden **clientseitig verschlüsselt** (Web Crypto API).
|
||||
## Funktionen
|
||||
|
||||
- **Passkey-Authentifizierung** (WebAuthn) mit optionaler Recovery-Phrase und lokalem PIN-Fallback
|
||||
- **Mehrere Logbücher** pro Benutzerkonto
|
||||
- **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)
|
||||
- **Kollaboration** — Crew per Einladungslink einladen
|
||||
- **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
|
||||
@@ -29,7 +32,7 @@ Alle sensiblen Inhalte werden **clientseitig verschlüsselt** (Web Crypto API).
|
||||
## Architektur
|
||||
|
||||
```
|
||||
┌─────────────────┐ HTTPS/API ┌─────────────────┐
|
||||
┌─────────────────┐ HTTPS/API ┌─────────────────┐
|
||||
│ React PWA │ ◄──────────────────► │ Express API │
|
||||
│ Vite + Dexie │ (nur ciphertext) │ Prisma + PG │
|
||||
│ IndexedDB │ │ PostgreSQL │
|
||||
@@ -44,6 +47,48 @@ Alle sensiblen Inhalte werden **clientseitig verschlüsselt** (Web Crypto API).
|
||||
| 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
|
||||
|
||||
@@ -52,13 +97,15 @@ kapteins-daagbok/
|
||||
├── client/ # React-PWA (Frontend)
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # UI-Komponenten
|
||||
│ │ ├── services/ # Auth, Sync, Krypto, Analytics, …
|
||||
│ │ ├── 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
|
||||
│ ├── src/routes/ # auth, logbooks, sync, collaboration, sign, push
|
||||
│ ├── src/services/ # z. B. pushNotify (Web Push)
|
||||
│ └── prisma/ # Datenbankschema
|
||||
├── docs/ # Projektdokumentation (z. B. Plausible Events)
|
||||
├── docs/ # Projektdokumentation
|
||||
├── scripts/ # Dev- und Deploy-Skripte
|
||||
├── docker-compose.yml # Produktions-Stack (DB + Backend + Frontend)
|
||||
└── VERSION # App-Version (Build & Footer)
|
||||
@@ -70,6 +117,7 @@ kapteins-daagbok/
|
||||
- **npm**
|
||||
- **Docker** (für PostgreSQL in der Entwicklung oder den vollständigen Stack)
|
||||
- Optional: OpenWeatherMap-API-Key (Wetter-Abruf in den Einstellungen)
|
||||
- Optional: VAPID-Schlüssel für Web Push (siehe Abschnitt Push-Benachrichtigungen)
|
||||
|
||||
## Lokale Entwicklung
|
||||
|
||||
@@ -94,6 +142,10 @@ Im `server/`-Verzeichnis eine `.env` mit `DATABASE_URL` anlegen, z. B.:
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/daagbox?schema=public"
|
||||
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
|
||||
@@ -126,7 +178,7 @@ Gesamten Stack lokal bauen und starten:
|
||||
|
||||
Frontend: http://localhost · API: http://localhost/api/health
|
||||
|
||||
Umgebungsvariablen für Passkeys in `.env` setzen (`RP_ID`, `ORIGIN`).
|
||||
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
|
||||
|
||||
@@ -138,11 +190,14 @@ Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen):
|
||||
|
||||
Standard-Ziel: `root@10.0.0.25:/opt/kapteins-daagbok` — per `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_DIR` überschreibbar.
|
||||
|
||||
Auf dem Server müssen `server/.env` (oder gleichwertige Umgebung) u. a. `DATABASE_URL`, `RP_ID`, `ORIGIN` 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 |
|
||||
| [.planning/PROJECT.md](.planning/PROJECT.md) | Produktvision und Anforderungen (GSD) |
|
||||
|
||||
## Analytics
|
||||
@@ -155,4 +210,4 @@ Aktuelle Version: siehe [VERSION](VERSION) (wird im App-Footer und beim Docker-B
|
||||
|
||||
---
|
||||
|
||||
© 2026 Markus F.J. Busche · [kapteins-daagbok.eu](https://kapteins-daagbok.eu)
|
||||
© 2026 KnorrLabs/Markus F.J. Busche · [kapteins-daagbok.eu](https://kapteins-daagbok.eu)
|
||||
|
||||
Generated
+3
-1
@@ -36,6 +36,9 @@
|
||||
"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 +2099,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
||||
@@ -38,5 +38,8 @@
|
||||
"typescript-eslint": "^8.59.2",
|
||||
"vite": "^8.0.12",
|
||||
"vite-plugin-pwa": "^1.3.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-linux-x64-gnu": "^1.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
+188
-1
@@ -839,6 +839,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 +931,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 +1073,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 +1430,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);
|
||||
}
|
||||
@@ -2953,10 +3046,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;
|
||||
}
|
||||
@@ -2975,6 +3072,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;
|
||||
|
||||
+161
-10
@@ -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'
|
||||
@@ -23,10 +23,14 @@ 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, BarChart2 } from 'lucide-react'
|
||||
import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
|
||||
@@ -35,6 +39,10 @@ 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()
|
||||
@@ -54,11 +62,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())
|
||||
@@ -103,19 +142,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')
|
||||
@@ -131,6 +198,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,
|
||||
@@ -153,9 +233,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()
|
||||
@@ -165,6 +289,7 @@ function App() {
|
||||
setDemoHighlightEntryId(demo.firstEntryId)
|
||||
}
|
||||
requestStartAfterLogin()
|
||||
consumePendingPushLogbook()
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -177,6 +302,7 @@ function App() {
|
||||
setActiveLogbookId(savedLogbookId)
|
||||
setActiveLogbookTitle(savedLogbookTitle)
|
||||
}
|
||||
consumePendingPushLogbook()
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
@@ -203,6 +329,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' }}>
|
||||
@@ -235,7 +374,7 @@ function App() {
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="auth-screen">
|
||||
<AuthOnboarding onAuthenticated={handleAuthenticated} />
|
||||
<AuthOnboarding onAuthenticated={handleAuthenticated} onOpenDemo={openDemo} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -267,8 +406,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>
|
||||
|
||||
@@ -385,7 +533,10 @@ function App() {
|
||||
*/}
|
||||
|
||||
{activeTab === 'settings' && (
|
||||
<SettingsForm logbookId={activeLogbookId} />
|
||||
<SettingsForm
|
||||
logbookId={activeLogbookId}
|
||||
onLogbookRestored={selectLogbook}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -7,9 +7,10 @@ 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">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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,20 +6,21 @@ 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'
|
||||
@@ -34,6 +35,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 +96,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 +110,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 +173,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 +182,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 +207,7 @@ export default function LogEntryEditor({
|
||||
}
|
||||
}
|
||||
|
||||
const buildPayloadForSigning = useCallback(() => {
|
||||
const buildPayloadForSigning = useCallback((eventsOverride?: LogEvent[]) => {
|
||||
return buildLogEntryPayload({
|
||||
date,
|
||||
dayOfTravel,
|
||||
@@ -188,7 +228,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 +238,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 +345,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 +455,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 +483,7 @@ export default function LogEntryEditor({
|
||||
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
|
||||
loadTrackStatsFromEntry(preloadedEntry)
|
||||
setEvents(preloadedEntry.events || [])
|
||||
setSavedFingerprint(fingerprintFromStoredEntry(preloadedEntry))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -411,6 +516,7 @@ export default function LogEntryEditor({
|
||||
setSignCrew(normalizeSignature(decrypted.signCrew) || '')
|
||||
loadTrackStatsFromEntry(decrypted)
|
||||
setEvents(decrypted.events || [])
|
||||
setSavedFingerprint(fingerprintFromStoredEntry(decrypted))
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
@@ -426,7 +532,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 {
|
||||
@@ -680,32 +791,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 +828,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 +943,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 +957,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 +1230,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 +1260,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 +1496,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 +1560,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 +1676,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,6 +3,7 @@ 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'
|
||||
@@ -82,7 +83,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 +107,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 */}
|
||||
@@ -201,42 +264,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,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,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,7 +11,8 @@ import {
|
||||
import {
|
||||
clearTourCompleted,
|
||||
isTourCompleted,
|
||||
markTourCompleted
|
||||
markTourCompleted,
|
||||
resolveTourUserId
|
||||
} from '../services/appTourStorage.js'
|
||||
import { getStoredDemoFirstEntryId } from '../services/demoLogbook.js'
|
||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||
@@ -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`)
|
||||
|
||||
@@ -38,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",
|
||||
@@ -114,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)",
|
||||
@@ -141,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",
|
||||
@@ -231,12 +243,21 @@
|
||||
"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.",
|
||||
"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",
|
||||
@@ -312,7 +333,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",
|
||||
@@ -336,7 +399,10 @@
|
||||
},
|
||||
"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",
|
||||
@@ -382,6 +448,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."
|
||||
|
||||
@@ -38,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",
|
||||
@@ -114,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)",
|
||||
@@ -141,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",
|
||||
@@ -231,12 +243,21 @@
|
||||
"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.",
|
||||
"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",
|
||||
@@ -312,7 +333,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",
|
||||
@@ -336,7 +399,10 @@
|
||||
},
|
||||
"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",
|
||||
@@ -382,6 +448,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."
|
||||
|
||||
@@ -17,7 +17,12 @@ export const PlausibleEvents = {
|
||||
PDF_EXPORTED: 'PDF Exported',
|
||||
CSV_EXPORTED: 'CSV Exported',
|
||||
CSV_SHARED: 'CSV Shared',
|
||||
PHOTO_UPLOADED: 'Photo Uploaded'
|
||||
PHOTO_UPLOADED: 'Photo Uploaded',
|
||||
BACKUP_EXPORTED: 'Backup Exported',
|
||||
BACKUP_RESTORED: 'Backup Restored',
|
||||
DEMO_OPENED: 'Demo Opened',
|
||||
PUSH_ENABLED: 'Push Enabled',
|
||||
PUSH_DISABLED: 'Push Disabled'
|
||||
} 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'
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
+68
-10
@@ -32,7 +32,48 @@ function entityKey(item: SyncQueueItem): string {
|
||||
return `${item.type}:${item.payloadId}`
|
||||
}
|
||||
|
||||
// Keep only the latest queue entry per entity; delete wins over create/update.
|
||||
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
|
||||
@@ -49,16 +90,20 @@ async function coalesceSyncQueue(logbookId: string): Promise<SyncQueueItem[]> {
|
||||
const staleIds: number[] = []
|
||||
|
||||
for (const group of byEntity.values()) {
|
||||
const deletes = group.filter((item) => item.action === 'delete')
|
||||
const latest =
|
||||
deletes.length > 0
|
||||
? deletes.reduce((a, b) => ((a.id ?? 0) > (b.id ?? 0) ? a : b))
|
||||
: group.reduce((a, b) => ((a.id ?? 0) > (b.id ?? 0) ? a : b))
|
||||
const winner = await resolveCoalescedItem(group)
|
||||
|
||||
kept.push(latest)
|
||||
for (const item of group) {
|
||||
if (item.id !== undefined && item.id !== latest.id) {
|
||||
staleIds.push(item.id)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -369,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 {
|
||||
|
||||
@@ -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)
|
||||
})()
|
||||
)
|
||||
})
|
||||
@@ -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,10 +38,12 @@ 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: {
|
||||
|
||||
@@ -24,14 +24,19 @@ 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`) | — |
|
||||
|
||||
## Bewusst nicht getrackt
|
||||
|
||||
@@ -47,6 +52,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
|
||||
```
|
||||
+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 {
|
||||
|
||||
@@ -6,6 +6,7 @@ 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 { prisma } from './db.js'
|
||||
|
||||
dotenv.config()
|
||||
@@ -22,6 +23,7 @@ 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)
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/api/health', async (req, res) => {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -218,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)
|
||||
@@ -225,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,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