Compare commits

..

29 Commits

Author SHA1 Message Date
elpatron 4f3f530f1f chore: release v0.1.0.35 2026-05-30 13:29:55 +02:00
elpatron 858d5d1d25 feat(feedback): optionales E-Mail-Kontaktfeld im Formular
Nutzer können optional eine E-Mail hinterlassen; Validierung client-/serverseitig, Weitergabe in Ntfy-Benachrichtigungen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 13:24:43 +02:00
elpatron c914156d70 fix(feedback): Erfolgsstatus inline anzeigen und Modal auto-schließen
Erfolgsmeldung erscheint im Formular statt hinter dem Modal; Schließen-Button oben rechts; Fehler inline.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 13:23:03 +02:00
elpatron 8bf89ed898 chore: release v0.1.0.34 2026-05-30 13:17:53 +02:00
elpatron adf8ee9929 fix(feedback): Ntfy in Docker, ASCII-Titel und Skipper-Badge
NTFY_* an den Backend-Container durchreichen; En-Dash im Ntfy-Header durch ASCII-Strich ersetzen (ByteString-Fehler). Skipper-Badge klar als Account-Anzeige kennzeichnen; start-dev.sh prüft npm vor dem Start.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 13:16:59 +02:00
elpatron 1055a12dad docs(marketing): Beta-Flyer (DIN A4) mit Playwright-Generierung
Skript und npm-Script zum Erzeugen des druckbaren Beta-Flyers als PDF.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 12:58:28 +02:00
elpatron f1f90da069 feat(feedback): Feedback-Formular mit Ntfy-Versand
Nutzer können Feedback aus dem Header senden; der Server leitet Nachrichten über Ntfy weiter (NTFY_* in .env).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 12:58:25 +02:00
elpatron 4541c81d3b chore: release v0.1.0.33 2026-05-30 12:39:29 +02:00
elpatron 03bb55f9a1 feat(weather): OWM-Fallback über Server-.env wenn kein User-Key
Wetter-Proxy auf /api/weather/current nutzt optionalen Nutzer-Key aus
den Einstellungen, sonst OpenWeatherMapAPIKey aus der Umgebung.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 12:37:58 +02:00
elpatron 69d5203305 chore: release v0.1.0.32 2026-05-30 12:20:05 +02:00
elpatron e8f9381c5f fix(docker): VAPID-Umgebungsvariablen an Backend durchreichen
Web Push benötigt VAPID_* aus der Host-.env im Backend-Container.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 12:19:50 +02:00
elpatron 442ddccceb feat(analytics): Plausible-Event für Footer-Link-Klick
Trackt „Footer Link Clicked“ beim Klick auf den Autoren-Link im App-Footer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 11:54:30 +02:00
elpatron f47413999c docs(seo): kostenlos und werbefrei in Meta-Tags ergänzen
Title, Description, Keywords, OG/Twitter sowie README und PWA-Manifest.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 11:50:32 +02:00
elpatron f23f0db70b chore: release v0.1.0.31 2026-05-30 11:46:26 +02:00
elpatron ece0abccbf docs: README um Web-Push-Dokumentation ergänzen
Beschreibt Opt-in, VAPID, iOS-PWA, Projektstruktur und Deployment-Hinweise.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 11:46:19 +02:00
elpatron 92e9020212 feat: Unterstützung für benutzerdefinierte Service Worker und Opt-in für Web Push-Benachrichtigungen
Ermöglicht es Logbuch-Eignern, benutzerdefinierte Service Worker zu verwenden und Web Push-Benachrichtigungen für Änderungen von Collaborators zu aktivieren, mit einem Opt-in in den Einstellungen.
2026-05-30 11:40:57 +02:00
elpatron 2428313a22 feat: Web Push für Logbuch-Eigner bei Crew-Sync
Benachrichtigt Owner optional per VAPID/Web Push, wenn Collaborators
Änderungen synchronisieren — ohne Klartext-Inhalte, mit Opt-in in den
Einstellungen, Custom Service Worker und Deep-Link zum Logbuch.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 11:36:03 +02:00
elpatron 0e61bc5dad chore: release v0.1.0.30 2026-05-30 11:14:21 +02:00
elpatron 585ef788df fix: Rückgabetyp von fingerprintSignature für Passkey-Signaturen korrigieren
Behebt den TypeScript-Buildfehler, da Passkey-Signaturen Objekte und keine Strings sind.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 11:14:15 +02:00
elpatron 9aabb2729d chore: release v0.1.0.29 2026-05-30 11:12:37 +02:00
elpatron ebe4199b8b fix: Footer-Copyright und Signatur-Fingerprint vereinheitlichen
Footer zeigt KnorrLabs/Markus F.J. Busche mit Mailto nur am Namen. Signatur-Normalisierung ist für Persistenz und isDirty-Check konsistent.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 11:12:26 +02:00
elpatron 10f01f1ffc chore: release v0.1.0.28 2026-05-30 11:08:18 +02:00
elpatron 29765d172e feat: Ereignisse sofort speichern und Save-Button bei Änderungen aktivieren
Ereignisprotokoll-Einträge werden direkt persistiert, ohne vorher die Logbuchseite zu speichern. Der Speichern-Button ist nur aktiv, wenn noch ungespeicherte Änderungen vorliegen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 11:07:54 +02:00
elpatron 5f9e83dbdd chore: release v0.1.0.27 2026-05-30 10:56:53 +02:00
elpatron aa2b35ddac feat: Ereignisprotokoll bearbeiten und Skipper-Signatur invalidieren
Bestehende Ereigniszeilen lassen sich nachträglich ändern; beim Speichern
oder Löschen wird nur die Skipper-Unterschrift entfernt, die Crew-Signatur bleibt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 10:56:45 +02:00
elpatron b5bc80594c chore: release v0.1.0.26 2026-05-30 10:41:35 +02:00
elpatron b88ce17e1d fix: Prevent signature alert loop when adding log events.
Stabilize dialog callbacks and dedupe signature-invalidation alerts so the UI no longer freezes after adding an event to a signed travel day.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 10:40:31 +02:00
elpatron 3849b5a2f0 chore: release v0.1.0.25 2026-05-30 10:18:24 +02:00
elpatron 1225601d7a fix: Demo navigation via history API and route sync.
Replace unreliable pathname assignment with pushState and central route syncing so the demo opens from the login screen and responds to browser back/forward.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-30 10:18:14 +02:00
46 changed files with 3673 additions and 211 deletions
+182
View File
@@ -0,0 +1,182 @@
---
name: merge
description: >-
Merge Git branches safely — fetch latest, merge or rebase onto master, resolve
conflicts intelligently, and verify the result. Use when the user asks to merge
branches, sync with master, resolve merge conflicts, or bring a feature branch
up to date.
---
# Git Merge
Führe Branch-Merges sicher und nachvollziehbar aus. Für PR-Review, CI und
Comment-Triage siehe den **babysit**-Skill — dieser Skill deckt die Git-Merge-
Operation selbst ab.
## Projekt-Kontext
- **Basis-Branch:** `master` (nicht `main`)
- **Monorepo:** `client/` (React PWA) und `server/` (Express API) — Konflikte
können in beiden liegen
## Sicherheitsregeln (immer einhalten)
- **Niemals** `git config` ändern
- **Niemals** `--no-verify`, `--no-gpg-sign` o.ä. ohne explizite Anfrage
- **Niemals** `push --force` auf `master` — bei Bedarf warnen und abbrechen
- **Niemals** destruktive Befehle (`reset --hard`, `clean -fd`) ohne explizite Anfrage
- **Niemals** interaktive Git-Befehle (`-i`-Flags) — nicht unterstützt
- **Kein Commit** ohne explizite Anfrage des Users
- **Kein Push** ohne explizite Anfrage des Users
## Workflow
### 1. Ausgangslage klären
Parallel ausführen:
```bash
git status
git branch -vv
git log --oneline -5
```
Ermittle:
- Aktueller Branch
- Ziel-Branch (Standard: `master`)
- Ob uncommittete Änderungen vorliegen
- Ob der Branch einen Remote-Tracking-Branch hat
**Bei uncommitteten Änderungen:** Stashen (`git stash push -m "pre-merge"`) nur
mit Zustimmung oder wenn der User es verlangt hat. Sonst stoppen und melden.
### 2. Merge-Strategie wählen
| Situation | Empfehlung |
|-----------|------------|
| Feature-Branch aktuell halten | `git merge origin/master` (Merge-Commit) |
| Linearer Verlauf gewünscht | `git rebase origin/master` (nur wenn User Rebase verlangt) |
| Zwei Feature-Branches zusammenführen | `git merge <branch>` auf Ziel-Branch |
**Standard:** Merge (nicht Rebase), es sei denn der User verlangt Rebase.
### 3. Remote aktualisieren
```bash
git fetch origin
```
Vor dem Merge prüfen, wie weit der Branch hinter `origin/master` liegt:
```bash
git log --oneline HEAD..origin/master
git log --oneline origin/master..HEAD
```
### 4. Merge ausführen
**Feature-Branch mit master synchronisieren** (häuigster Fall):
```bash
git checkout <feature-branch>
git merge origin/master
```
**Branch in master mergen** (nur wenn User das ausdrücklich will — normalerweise
passiert das via PR):
```bash
git checkout master
git pull origin master
git merge <feature-branch>
```
Merge-Commit-Nachricht kurz und sachlich halten, z.B.:
`Merge branch 'master' into feature/push-notifications-owner`
### 5. Konflikte lösen
Konfliktdateien finden:
```bash
git diff --name-only --diff-filter=U
```
**Pro Konfliktdatei:**
1. Datei lesen und beide Seiten verstehen (HEAD = eigener Branch, incoming = gemergter Branch)
2. Intent beider Änderungen erhalten — nicht blind eine Seite wählen
3. Konfliktmarker entfernen (`<<<<<<<`, `=======`, `>>>>>>>`)
4. Bei widersprüchlicher Intent: Merge abbrechen und User fragen
```bash
git merge --abort # oder: git rebase --abort
```
**Typische Konflikt-Muster in diesem Projekt:**
| Bereich | Hinweis |
|---------|---------|
| `package-lock.json` | Nach manueller Lösung `npm install` im betroffenen Paket (`client/` oder `server/`) ausführen |
| i18n (`client/src/i18n/`) | Beide Sprachkeys (DE + EN) behalten, keine Keys verlieren |
| Prisma/Schema | Migrationen beider Seiten zusammenführen, nicht überschreiben |
| Verschlüsselung/Auth | Vorsichtig — keine Sicherheitslogik stillschweigend vereinfachen |
Nach jeder gelösten Datei:
```bash
git add <file>
```
Merge abschließen (nur wenn User Commit verlangt hat):
```bash
git commit -m "$(cat <<'EOF'
Merge branch 'master' into <feature-branch>
EOF
)"
```
### 6. Verifizieren
Nach erfolgreichem Merge:
```bash
git status
git log --oneline -5
```
Relevante Checks je nach betroffenen Bereichen:
```bash
# Client
cd client && npm run build
# Server
cd server && npm run build
```
Bei Lockfile-Konflikten oder Dependency-Änderungen: Build in beiden Paketen prüfen.
### 7. Abschluss
- Ergebnis dem User mitteilen: welche Branches, wie viele Konflikte, was gelöst wurde
- Bei `git stash`: erinnern, Stash wieder anzuwenden (`git stash pop`)
- Push nur auf explizite Anfrage: `git push origin <branch>`
## Wann abbrechen und fragen
- Widersprüchliche fachliche Intent (z.B. beide Seiten ändern dieselbe Logik unterschiedlich)
- Konflikte in Krypto-, Auth- oder Sync-Kernlogik ohne klares „richtig“
- Merge würde `.env`, Credentials oder Secrets einschließen
- User wollte nur Status prüfen, nicht tatsächlich mergen
## Abgrenzung zu anderen Skills
| Skill | Wann |
|-------|------|
| **merge** (dieser) | Git merge/rebase, Konflikte, Branch sync |
| **babysit** | PR merge-ready: Comments, CI, PR-Konflikte im PR-Kontext |
| **creating-pull-requests** | PR erstellen und pushen |
+13 -1
View File
@@ -4,4 +4,16 @@ OpenWeatherMapAPIKey=<owm_api_key>
# For local dev: localhost and http://localhost
# For production: e.g. kapteins-daagbok.eu and https://kapteins-daagbok.eu
RP_ID=localhost
ORIGIN=http://localhost
ORIGIN=http://localhost
# Web Push (VAPID) — generate with: npx web-push generate-vapid-keys
# Public key may also be set on the client as VITE_VAPID_PUBLIC_KEY
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu
# Feedback via Ntfy (https://ntfy.sh or self-hosted)
# NTFY_TOPIC: topic name only (not the full URL)
NTFY_SERVER=https://ntfy.sh
NTFY_TOPIC=kapteins-daagbok-feedback
NTFY_TOKEN=tk_example_ntfy_access_token
+43 -8
View File
@@ -1,6 +1,6 @@
# Kapteins Daagbok
Digitales Yacht-Logbuch als Progressive Web App (PWA) — offline-fähig, End-to-End-verschlüsselt und mit Passkey-Anmeldung.
Digitales Yacht-Logbuch als Progressive Web App (PWA) — **kostenlos**, **werbefrei**, offline-fähig, End-to-End-verschlüsselt und mit Passkey-Anmeldung.
**Live:** [kapteins-daagbok.eu](https://kapteins-daagbok.eu)
@@ -21,6 +21,7 @@ Alle sensiblen Inhalte werden **clientseitig verschlüsselt** (Web Crypto API).
- **Schiffsdaten** und **Crew-Profile** (Skipper + Mitglieder)
- **Statistik-Dashboard** — Strecken, Verbrauch, Segel/Motor, Hafenkette (pro Logbuch oder accountweit)
- **Kollaboration** — Crew per Einladungslink einladen (Schreib- oder Lesezugriff)
- **Push-Benachrichtigungen** (optional) — Logbuch-Eigner per Web Push informieren, wenn Crew Änderungen synchronisiert (ohne Klartext-Inhalte; Opt-in in den Einstellungen)
- **Read-only-Freigabe** — öffentlicher Lese-Link für Dritte
- **Export** — PDF pro Reisetag, CSV-Download/-Teilen
- **Backup & Wiederherstellung** — vollständiges verschlüsseltes Logbuch-Backup (Einträge, Fotos, GPS, Crew, Schiff) als `.daagbok.json`; Restore auf gleichem oder neuem Account
@@ -46,12 +47,13 @@ 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 |
| **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) |
@@ -67,6 +69,27 @@ Nur der **Logbuch-Eigner** kann unter **Einstellungen → Backup & Wiederherstel
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
```
@@ -74,13 +97,15 @@ kapteins-daagbok/
├── client/ # React-PWA (Frontend)
│ ├── src/
│ │ ├── components/ # UI-Komponenten
│ │ ├── services/ # Auth, Sync, Krypto, Backup, 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)
@@ -91,7 +116,8 @@ kapteins-daagbok/
- **Node.js** 20+
- **npm**
- **Docker** (für PostgreSQL in der Entwicklung oder den vollständigen Stack)
- Optional: OpenWeatherMap-API-Key (Wetter-Abruf in den Einstellungen)
- Optional: eigener OpenWeatherMap-API-Key in den Einstellungen (sonst serverseitiger Key aus `.env`)
- Optional: VAPID-Schlüssel für Web Push (siehe Abschnitt Push-Benachrichtigungen)
## Lokale Entwicklung
@@ -110,12 +136,17 @@ cp .env.example .env
Für lokale Passkeys: `RP_ID=localhost`, `ORIGIN=http://localhost:5173` (bzw. die tatsächliche Frontend-URL).
Im `server/`-Verzeichnis eine `.env` mit `DATABASE_URL` anlegen, z. B.:
Im `server/`-Verzeichnis eine `.env` mit `DATABASE_URL` anlegen — oder den Key in der **Projekt-`.env`** (`OpenWeatherMapAPIKey=...`); das Backend lädt beide Dateien.
```
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/daagbox?schema=public"
OpenWeatherMapAPIKey= # Fallback für Wetter-Abruf, wenn Nutzer keinen eigenen Key hat
RP_ID=localhost
ORIGIN=http://localhost:5173
# Optional — Web Push (npx web-push generate-vapid-keys)
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu
```
### 3. Datenbank & Schema
@@ -148,7 +179,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
@@ -160,11 +191,15 @@ 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 |
| [docs/marketing/kapteins-daagbok-beta-flyer.pdf](docs/marketing/kapteins-daagbok-beta-flyer.pdf) | Beta-Flyer (DIN A4) zum Ausdrucken — Quelle: `docs/marketing/beta-flyer.html`, neu erzeugen: `cd client && npm run generate:flyer` |
| [.planning/PROJECT.md](.planning/PROJECT.md) | Produktvision und Anforderungen (GSD) |
## Analytics
+1 -1
View File
@@ -1 +1 @@
0.1.0.25
0.1.0.36
+7 -7
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Digitales Yacht-Logbuch mit End-to-End-Verschlüsselung und Passkey-Anmeldung. Reisetage, GPS-Tracks, Crew und Schiffsdaten sicher dokumentieren auch offline als PWA." />
<meta name="keywords" content="Yacht-Logbuch, Schiffstagebuch, Bordlogbuch, Segeln, Passkey, E2E-Verschlüsselung, GPS-Track, maritimes Logbuch" />
<meta name="description" content="Kostenloses, werbefreies digitales Yacht-Logbuch mit End-to-End-Verschlüsselung und Passkey-Anmeldung. Reisetage, GPS-Tracks, Crew und Schiffsdaten sicher dokumentieren auch offline als PWA." />
<meta name="keywords" content="Yacht-Logbuch, Schiffstagebuch, Bordlogbuch, Segeln, Passkey, E2E-Verschlüsselung, GPS-Track, maritimes Logbuch, kostenlos, werbefrei, gratis, ohne Werbung" />
<meta name="author" content="Markus F.J. Busche" />
<meta name="robots" content="index, follow" />
<meta name="application-name" content="Kapteins Daagbok" />
@@ -18,20 +18,20 @@
<link rel="apple-touch-icon" href="/logo.png" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Kapteins Daagbok" />
<meta property="og:title" content="Kapteins Daagbok Digitales Yacht-Logbuch" />
<meta property="og:description" content="Sicheres, E2E-verschlüsseltes Logbuch für Skipper: Reisetage, GPS-Tracks, Crew- und Schiffsdaten mit Passkey-Anmeldung und Offline-PWA." />
<meta property="og:title" content="Kapteins Daagbok Kostenloses digitales Yacht-Logbuch" />
<meta property="og:description" content="Kostenlos und werbefrei: sicheres, E2E-verschlüsseltes Logbuch für Skipper. Reisetage, GPS-Tracks, Crew- und Schiffsdaten Passkey-Anmeldung und Offline-PWA." />
<meta property="og:url" content="https://kapteins-daagbok.eu/" />
<meta property="og:image" content="https://kapteins-daagbok.eu/logo.png" />
<meta property="og:image:alt" content="Kapteins Daagbok Logo" />
<meta property="og:locale" content="de_DE" />
<meta property="og:locale:alternate" content="en_US" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="Kapteins Daagbok Digitales Yacht-Logbuch" />
<meta name="twitter:description" content="Sicheres, E2E-verschlüsseltes Logbuch für Skipper: Reisetage, GPS-Tracks, Crew- und Schiffsdaten mit Passkey-Anmeldung und Offline-PWA." />
<meta name="twitter:title" content="Kapteins Daagbok Kostenloses digitales Yacht-Logbuch" />
<meta name="twitter:description" content="Kostenlos und werbefrei: sicheres, E2E-verschlüsseltes Logbuch für Skipper. Reisetage, GPS-Tracks, Crew- und Schiffsdaten Passkey-Anmeldung und Offline-PWA." />
<meta name="twitter:image" content="https://kapteins-daagbok.eu/logo.png" />
<meta name="twitter:image:alt" content="Kapteins Daagbok Logo" />
<script defer data-domain="kapteins-daagbok.eu" src="https://plausible.elpatron.me/js/script.tagged-events.js"></script>
<title>Kapteins Daagbok Digitales Yacht-Logbuch</title>
<title>Kapteins Daagbok Kostenloses digitales Yacht-Logbuch (werbefrei)</title>
</head>
<body>
<div id="root"></div>
+373
View File
@@ -32,6 +32,8 @@
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"playwright": "^1.51.0",
"qrcode": "^1.5.4",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12",
@@ -3109,6 +3111,32 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/array-buffer-byte-length": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz",
@@ -3379,6 +3407,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001793",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz",
@@ -3420,6 +3458,38 @@
"node": ">=10.0.0"
}
},
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
@@ -3584,6 +3654,16 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -3663,6 +3743,13 @@
"react": ">=16"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"dev": true,
"license": "MIT"
},
"node_modules/dompurify": {
"version": "3.4.7",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz",
@@ -3711,6 +3798,13 @@
"dev": true,
"license": "ISC"
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/es-abstract": {
"version": "1.24.2",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
@@ -4385,6 +4479,16 @@
"node": ">=6.9.0"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -4907,6 +5011,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-generator-function": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
@@ -5861,6 +5975,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@@ -5955,6 +6079,63 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.51.0.tgz",
"integrity": "sha512-442pTfGM0xxfCYxuBa/Pu6B2OqxqqaYq39JS8QDMGThUvIOCd6s0ANDog3uwA0cHavVlnTQzGCN7Id2YekDSXA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.51.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.51.0.tgz",
"integrity": "sha512-x47yPE3Zwhlil7wlNU/iktF7t2r/URR3VLbH6EknJd/04Qc/PSJ0EY3CMXipmglLG+zyRxW6HNo2EGbKLHPWMg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -6027,6 +6208,24 @@
"node": ">=6"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/raf": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
@@ -6194,6 +6393,16 @@
"regjsparser": "bin/parser"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -6204,6 +6413,13 @@
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"dev": true,
"license": "ISC"
},
"node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@@ -6403,6 +6619,13 @@
"node": ">=20.0.0"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"dev": true,
"license": "ISC"
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -6643,6 +6866,21 @@
"node": ">= 0.4"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/string.prototype.matchall": {
"version": "4.0.12",
"resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz",
@@ -6745,6 +6983,19 @@
"node": ">=4"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz",
@@ -7410,6 +7661,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"dev": true,
"license": "ISC"
},
"node_modules/which-typed-array": {
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.21.tgz",
@@ -7662,6 +7920,28 @@
"workbox-core": "7.4.1"
}
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"dev": true,
"license": "ISC"
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@@ -7669,6 +7949,99 @@
"dev": true,
"license": "ISC"
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/yargs/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yargs/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+5 -1
View File
@@ -7,7 +7,9 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"generate:flyer": "node ../scripts/generate-beta-flyer.mjs",
"generate:flyer:setup": "playwright install chromium"
},
"dependencies": {
"@simplewebauthn/browser": "^13.3.0",
@@ -34,6 +36,8 @@
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"playwright": "^1.51.0",
"qrcode": "^1.5.4",
"typescript": "~6.0.2",
"typescript-eslint": "^8.59.2",
"vite": "^8.0.12",
+129 -4
View File
@@ -388,6 +388,106 @@ html.scheme-dark .themed-select-option.is-selected {
max-height: min(90vh, 820px);
}
.feedback-modal {
position: relative;
}
.feedback-modal__close {
top: 12px;
right: 12px;
z-index: 1;
}
.feedback-status {
text-align: center;
padding: 8px 0 4px;
}
.feedback-status p {
margin: 12px 0 0;
font-size: 15px;
line-height: 1.5;
}
.feedback-status--success {
color: #4ade80;
padding: 24px 8px 32px;
}
.feedback-status--success p {
color: var(--app-text-heading, #f1f5f9);
}
.feedback-status--error {
color: var(--app-error-text, #fda4af);
background: var(--app-error-bg, rgba(244, 63, 94, 0.08));
border: 1px solid var(--app-error-border, #f43f5e);
border-radius: 8px;
margin-bottom: 16px;
padding: 10px 12px;
text-align: left;
}
.feedback-status--error p {
margin: 0;
font-size: 14px;
}
.feedback-modal .auth-actions {
margin-top: 0;
}
.feedback-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.feedback-form__field {
display: flex;
flex-direction: column;
gap: 6px;
text-align: left;
}
.feedback-form__field > span {
font-size: 13px;
font-weight: 600;
color: var(--app-text-heading, #f1f5f9);
}
.feedback-form__field select,
.feedback-form__field input,
.feedback-form__field textarea {
width: 100%;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--app-input-border, rgba(148, 163, 184, 0.25));
background: var(--app-input-bg, rgba(15, 23, 42, 0.6));
color: var(--app-text, #e2e8f0);
font: inherit;
resize: vertical;
}
.feedback-form__field select:focus,
.feedback-form__field input:focus,
.feedback-form__field textarea:focus {
outline: none;
border-color: var(--app-accent, #38bdf8);
}
.feedback-form__actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.feedback-form__actions .btn {
width: auto;
min-width: 100px;
margin: 0;
}
.registration-disclaimer__intro {
margin: 0 0 16px;
font-size: 14px;
@@ -619,9 +719,18 @@ html.scheme-dark .themed-select-option.is-selected {
font-size: 13px;
padding: 6px 12px;
border-radius: 20px;
background: var(--app-btn-secondary-bg);
border: 1px solid var(--app-btn-secondary-border);
color: var(--app-btn-secondary-text);
background: rgba(148, 163, 184, 0.08);
border: 1px solid rgba(148, 163, 184, 0.18);
color: var(--app-text-muted);
cursor: default;
user-select: none;
}
.skipper-badge__name {
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.btn-icon {
@@ -1430,6 +1539,18 @@ html.scheme-dark .themed-select-option.is-selected {
vertical-align: middle;
}
.events-actions-td {
white-space: nowrap;
}
.events-actions-td .btn-icon {
margin-left: 4px;
}
.events-actions-td .btn-icon:first-child {
margin-left: 0;
}
.events-table tbody tr:hover {
background: rgba(255, 255, 255, 0.02);
}
@@ -3034,10 +3155,14 @@ html.theme-cupertino .events-scroll-container {
.app-version-footer__copyright {
color: #94a3b8;
}
.app-version-footer__copyright a {
color: inherit;
text-decoration: none;
}
.app-version-footer__copyright:hover {
.app-version-footer__copyright a:hover {
color: #e2e8f0;
text-decoration: underline;
}
+99 -7
View File
@@ -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'
@@ -34,11 +34,16 @@ 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'
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
import { useTranslation } from 'react-i18next'
import {
getStoredDemoFirstEntryId,
seedDemoLogbookIfNeeded
} from './services/demoLogbook.js'
import { fetchLogbooks } from './services/logbook.js'
import { ensurePushSubscriptionIfEnabled } from './services/pushNotifications.js'
const PENDING_PUSH_LOGBOOK_KEY = 'pending_push_logbook_id'
function App() {
const { t, i18n } = useTranslation()
@@ -59,7 +64,7 @@ function App() {
const [shareKey, setShareKey] = useState('')
// Public demo mode (no account required)
const [isDemoMode, setIsDemoMode] = useState(false)
const [isDemoMode, setIsDemoMode] = useState(() => window.location.pathname === '/demo')
const syncQueueCount = useLiveQuery(
() => activeLogbookId ? db.syncQueue.where({ logbookId: activeLogbookId }).count() : db.syncQueue.count(),
@@ -138,24 +143,47 @@ function App() {
}
}, [isAuthenticated])
useEffect(() => {
const syncRouteFromLocation = useCallback(() => {
const params = new URLSearchParams(window.location.search)
const hashParams = new URLSearchParams(window.location.hash.substring(1))
const path = window.location.pathname
if (window.location.pathname === '/demo') {
if (path === '/demo') {
setIsDemoMode(true)
setIsViewerMode(false)
setIsAcceptingInvite(false)
return
}
if (window.location.pathname === '/share' && params.has('token') && hashParams.has('key')) {
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')
@@ -171,6 +199,19 @@ function App() {
}
}, [])
useEffect(() => {
syncRouteFromLocation()
window.addEventListener('popstate', syncRouteFromLocation)
return () => window.removeEventListener('popstate', syncRouteFromLocation)
}, [syncRouteFromLocation])
const openDemo = useCallback(() => {
window.history.pushState({}, document.title, '/demo')
setIsDemoMode(true)
setIsViewerMode(false)
setIsAcceptingInvite(false)
}, [])
useEffect(() => {
registerNavigation({
setActiveTab,
@@ -193,9 +234,53 @@ function App() {
localStorage.setItem('active_logbook_title', title)
}
const openLogbookById = useCallback(
async (logbookId: string) => {
try {
const books = await fetchLogbooks()
const match = books.find((b) => b.id === logbookId)
if (match) {
selectLogbook(match.id, match.title)
return
}
} catch (err) {
console.error('Failed to resolve logbook from push:', err)
}
selectLogbook(logbookId, `${logbookId.slice(0, 8)}`)
},
[]
)
const consumePendingPushLogbook = useCallback(() => {
const pending = sessionStorage.getItem(PENDING_PUSH_LOGBOOK_KEY)
if (!pending) return
sessionStorage.removeItem(PENDING_PUSH_LOGBOOK_KEY)
void openLogbookById(pending)
}, [openLogbookById])
useEffect(() => {
if (isAuthenticated) {
consumePendingPushLogbook()
}
}, [isAuthenticated, consumePendingPushLogbook])
useEffect(() => {
if (!isAuthenticated || !('serviceWorker' in navigator)) return
const onSwMessage = (event: MessageEvent) => {
if (event.data?.type === 'OPEN_LOGBOOK' && typeof event.data.logbookId === 'string') {
void openLogbookById(event.data.logbookId)
}
}
navigator.serviceWorker.addEventListener('message', onSwMessage)
return () => navigator.serviceWorker.removeEventListener('message', onSwMessage)
}, [isAuthenticated, openLogbookById])
const handleAuthenticated = async () => {
setIsAuthenticated(true)
trackPlausibleEvent(PlausibleEvents.LOGGED_IN)
void ensurePushSubscriptionIfEnabled()
try {
const demo = await seedDemoLogbookIfNeeded()
@@ -205,6 +290,7 @@ function App() {
setDemoHighlightEntryId(demo.firstEntryId)
}
requestStartAfterLogin()
consumePendingPushLogbook()
return
}
} catch (err) {
@@ -217,6 +303,7 @@ function App() {
setActiveLogbookId(savedLogbookId)
setActiveLogbookTitle(savedLogbookTitle)
}
consumePendingPushLogbook()
}
const handleLogout = () => {
@@ -245,7 +332,7 @@ function App() {
const handleExitDemo = () => {
window.history.replaceState({}, document.title, '/')
setIsDemoMode(false)
syncRouteFromLocation()
}
if (isDemoMode) {
@@ -288,7 +375,7 @@ function App() {
if (!isAuthenticated) {
return (
<div className="auth-screen">
<AuthOnboarding onAuthenticated={handleAuthenticated} />
<AuthOnboarding onAuthenticated={handleAuthenticated} onOpenDemo={openDemo} />
</div>
)
}
@@ -353,6 +440,11 @@ function App() {
<DisclaimerHeaderButton />
<FeedbackHeaderButton
logbookId={activeLogbookId}
logbookTitle={activeLogbookTitle}
/>
<button className="btn-icon logout" onClick={handleLogout} title={t('dashboard.logout')}>
<LogOut size={18} />
</button>
+11 -3
View File
@@ -1,3 +1,5 @@
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'
export default function AppFooter() {
@@ -7,9 +9,15 @@ export default function AppFooter() {
<span className="app-version-footer__sep" aria-hidden="true">
·
</span>
<a className="app-version-footer__copyright" href="mailto:elpatron+kd@mailbox.org">
© 2026 Markus F.J. Busche
</a>
<span className="app-version-footer__copyright">
© 2026 KnorrLabs/
<a
href="mailto:elpatron+kd@mailbox.org"
onClick={() => trackPlausibleEvent(PlausibleEvents.FOOTER_LINK_CLICKED)}
>
Markus F.J. Busche
</a>
</span>
</footer>
)
}
+3 -2
View File
@@ -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)
@@ -526,7 +527,7 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
<button
type="button"
className="btn secondary"
onClick={() => { window.location.pathname = '/demo' }}
onClick={() => onOpenDemo?.()}
disabled={loading}
style={{ width: '100%' }}
>
@@ -0,0 +1,37 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { MessageSquarePlus } from 'lucide-react'
import FeedbackModal from './FeedbackModal.tsx'
interface FeedbackHeaderButtonProps {
logbookId?: string | null
logbookTitle?: string | null
}
export default function FeedbackHeaderButton({
logbookId,
logbookTitle
}: FeedbackHeaderButtonProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
return (
<>
<button
type="button"
className="btn-icon"
onClick={() => setOpen(true)}
title={t('feedback.button_title')}
aria-label={t('feedback.button_title')}
>
<MessageSquarePlus size={18} />
</button>
<FeedbackModal
open={open}
onClose={() => setOpen(false)}
logbookId={logbookId}
logbookTitle={logbookTitle}
/>
</>
)
}
+209
View File
@@ -0,0 +1,209 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { CheckCircle2, MessageSquarePlus, X } from 'lucide-react'
import { FeedbackApiError, sendFeedback, type FeedbackCategory } from '../services/feedback.js'
const SUCCESS_CLOSE_DELAY_MS = 1800
interface FeedbackModalProps {
open: boolean
onClose: () => void
logbookId?: string | null
logbookTitle?: string | null
}
type SubmitState = 'idle' | 'submitting' | 'success' | 'error'
export default function FeedbackModal({
open,
onClose,
logbookId,
logbookTitle
}: FeedbackModalProps) {
const { t } = useTranslation()
const [category, setCategory] = useState<FeedbackCategory>('general')
const [contactEmail, setContactEmail] = useState('')
const [message, setMessage] = useState('')
const [submitState, setSubmitState] = useState<SubmitState>('idle')
const [statusMessage, setStatusMessage] = useState<string | null>(null)
const closeTimerRef = useRef<number | null>(null)
const isBusy = submitState === 'submitting' || submitState === 'success'
const clearCloseTimer = () => {
if (closeTimerRef.current !== null) {
window.clearTimeout(closeTimerRef.current)
closeTimerRef.current = null
}
}
useEffect(() => {
return () => clearCloseTimer()
}, [])
useEffect(() => {
if (!open) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !isBusy) onClose()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [open, onClose, isBusy])
useEffect(() => {
if (!open) {
clearCloseTimer()
setCategory('general')
setContactEmail('')
setMessage('')
setSubmitState('idle')
setStatusMessage(null)
}
}, [open])
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault()
if (!message.trim() || submitState === 'submitting' || submitState === 'success') return
setSubmitState('submitting')
setStatusMessage(null)
try {
await sendFeedback({
category,
message: message.trim(),
contactEmail: contactEmail.trim() || undefined,
logbookId,
logbookTitle
})
setSubmitState('success')
setStatusMessage(t('feedback.success'))
closeTimerRef.current = window.setTimeout(() => {
closeTimerRef.current = null
onClose()
}, SUCCESS_CLOSE_DELAY_MS)
} catch (error) {
setSubmitState('error')
setStatusMessage(
error instanceof FeedbackApiError && error.code === 'NOT_CONFIGURED'
? t('feedback.error_not_configured')
: error instanceof FeedbackApiError && error.code === 'INVALID_EMAIL'
? t('feedback.error_invalid_email')
: t('feedback.error_send')
)
}
}
if (!open) return null
return (
<div className="disclaimer-modal-overlay" onClick={isBusy ? undefined : onClose}>
<div className="disclaimer-modal-panel" onClick={(event) => event.stopPropagation()}>
<div className="auth-card glass registration-disclaimer registration-disclaimer--modal feedback-modal">
<button
type="button"
className="registration-disclaimer__close feedback-modal__close"
onClick={onClose}
disabled={isBusy}
aria-label={t('feedback.cancel')}
>
<X size={18} />
</button>
<div className="auth-header">
<MessageSquarePlus className="auth-icon accent" size={48} />
<h2>{t('feedback.title')}</h2>
</div>
{submitState === 'success' ? (
<div className="feedback-status feedback-status--success" role="status" aria-live="polite">
<CheckCircle2 size={40} aria-hidden="true" />
<p>{statusMessage}</p>
</div>
) : (
<>
<p className="registration-disclaimer__intro">{t('feedback.intro')}</p>
{statusMessage && submitState === 'error' && (
<div className="feedback-status feedback-status--error" role="alert">
<p>{statusMessage}</p>
</div>
)}
<form className="feedback-form" onSubmit={handleSubmit}>
<label className="feedback-form__field">
<span>{t('feedback.category_label')}</span>
<select
value={category}
onChange={(event) => setCategory(event.target.value as FeedbackCategory)}
disabled={submitState === 'submitting'}
>
<option value="general">{t('feedback.category_general')}</option>
<option value="bug">{t('feedback.category_bug')}</option>
<option value="feature">{t('feedback.category_feature')}</option>
</select>
</label>
<label className="feedback-form__field">
<span>{t('feedback.contact_label')}</span>
<input
type="email"
value={contactEmail}
onChange={(event) => {
setContactEmail(event.target.value)
if (submitState === 'error') {
setSubmitState('idle')
setStatusMessage(null)
}
}}
placeholder={t('feedback.contact_placeholder')}
autoComplete="email"
maxLength={254}
disabled={submitState === 'submitting'}
/>
</label>
<label className="feedback-form__field">
<span>{t('feedback.message_label')}</span>
<textarea
value={message}
onChange={(event) => {
setMessage(event.target.value)
if (submitState === 'error') {
setSubmitState('idle')
setStatusMessage(null)
}
}}
placeholder={t('feedback.message_placeholder')}
rows={6}
maxLength={2000}
required
disabled={submitState === 'submitting'}
/>
</label>
<div className="auth-actions feedback-form__actions">
<button
type="button"
className="btn secondary"
onClick={onClose}
disabled={submitState === 'submitting'}
>
{t('feedback.cancel')}
</button>
<button
type="submit"
className="btn primary"
disabled={submitState === 'submitting' || !message.trim()}
>
{submitState === 'submitting' ? t('feedback.sending') : t('feedback.send')}
</button>
</div>
</form>
</>
)}
</div>
</div>
</div>
)
}
+329 -149
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { db } from '../services/db.js'
import { getActiveMasterKey } from '../services/auth.js'
@@ -6,24 +6,26 @@ import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js'
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload } from 'lucide-react'
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X } from 'lucide-react'
import PhotoCapture from './PhotoCapture.tsx'
import SignatureSection from './SignatureSection.tsx'
import TrackMap from './TrackMap.tsx'
import { useDialog } from './ModalDialog.tsx'
import {
normalizeSignature,
serializeSignature,
fingerprintSignature,
normalizedSerializedSignature,
isPasskeySignature,
isSignatureValidForEntry,
hasAnySignature
} from '../utils/signatures.js'
import type { SignatureValue } from '../types/signatures.js'
import { buildLogEntryPayload } from '../utils/logEntryPayload.js'
import { buildLogEntryPayload, type LogEventPayload } from '../utils/logEntryPayload.js'
import { hashEntryForSigning } from '../utils/entryCanonicalHash.js'
import { signLogEntry } from '../services/entrySigning.js'
import { getLogbookAccess } from '../services/logbookAccess.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { fetchOpenWeatherCurrent, WeatherApiError } from '../services/weather.js'
import {
getDecryptedTrack,
saveUploadedTrack,
@@ -34,6 +36,56 @@ import {
} from '../services/trackUpload.js'
import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
function emptyTankLevels() {
return { morning: 0, refilled: 0, evening: 0, consumption: 0 }
}
function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string {
const fw = (decrypted.freshwater as Record<string, number> | undefined) ?? emptyTankLevels()
const fuel = (decrypted.fuel as Record<string, number> | undefined) ?? emptyTankLevels()
const trackDistance = decrypted.trackDistanceNm
const trackSpeedMax = decrypted.trackSpeedMaxKn
const trackSpeedAvg = decrypted.trackSpeedAvgKn
const payload = buildLogEntryPayload({
date: String(decrypted.date || ''),
dayOfTravel: String(decrypted.dayOfTravel || ''),
departure: String(decrypted.departure || ''),
destination: String(decrypted.destination || ''),
freshwater: {
morning: fw.morning || 0,
refilled: fw.refilled || 0,
evening: fw.evening || 0,
consumption: fw.consumption ?? 0
},
fuel: {
morning: fuel.morning || 0,
refilled: fuel.refilled || 0,
evening: fuel.evening || 0,
consumption: fuel.consumption ?? 0
},
trackDistanceNm:
trackDistance != null && trackDistance !== ''
? parseFloat(String(trackDistance))
: undefined,
trackSpeedMaxKn:
trackSpeedMax != null && trackSpeedMax !== ''
? parseFloat(String(trackSpeedMax))
: undefined,
trackSpeedAvgKn:
trackSpeedAvg != null && trackSpeedAvg !== ''
? parseFloat(String(trackSpeedAvg))
: undefined,
events: (decrypted.events as LogEventPayload[]) || []
})
return JSON.stringify({
...payload,
signSkipper: fingerprintSignature(decrypted.signSkipper),
signCrew: fingerprintSignature(decrypted.signCrew)
})
}
interface LogEntryEditorProps {
entryId: string
logbookId: string
@@ -45,24 +97,7 @@ interface LogEntryEditorProps {
preloadedYacht?: any
}
interface LogEvent {
time: string
mgk: string
rwk: string
windPressure: string
windDirection: string
windStrength: string
seaState: string
weatherIcon: string
current: string
heel: string
sailsOrMotor: string
logReading: string
distance: string
gpsLat: string
gpsLng: string
remarks: string
}
interface LogEvent extends LogEventPayload {}
export default function LogEntryEditor({
entryId,
@@ -76,6 +111,8 @@ export default function LogEntryEditor({
}: LogEntryEditorProps) {
const { t, i18n } = useTranslation()
const { showAlert, showConfirm } = useDialog()
const showAlertRef = useRef(showAlert)
showAlertRef.current = showAlert
// General details state
const [date, setDate] = useState('')
@@ -137,6 +174,7 @@ export default function LogEntryEditor({
const [success, setSuccess] = useState(false)
const [error, setError] = useState<string | null>(null)
const [weatherLoading, setWeatherLoading] = useState(false)
const [savedFingerprint, setSavedFingerprint] = useState<string | null>(null)
// Track file upload
const [savedTrack, setSavedTrack] = useState<SavedTrack | null>(null)
@@ -145,6 +183,9 @@ export default function LogEntryEditor({
const fileInputRef = useRef<HTMLInputElement | null>(null)
const lockedContentHashRef = useRef<string | null>(null)
const contentReadyRef = useRef(false)
const lastSignatureAlertHashRef = useRef<string | null>(null)
const skipCrewSignClearRef = useRef(false)
const [editingEventIndex, setEditingEventIndex] = useState<number | null>(null)
const applyTrackStats = (waypoints: SavedTrack['waypoints']) => {
const stats = computeTrackStats(waypoints)
@@ -167,7 +208,7 @@ export default function LogEntryEditor({
}
}
const buildPayloadForSigning = useCallback(() => {
const buildPayloadForSigning = useCallback((eventsOverride?: LogEvent[]) => {
return buildLogEntryPayload({
date,
dayOfTravel,
@@ -188,7 +229,7 @@ export default function LogEntryEditor({
trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined,
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
events
events: eventsOverride ?? events
})
}, [
date, dayOfTravel, departure, destination,
@@ -198,6 +239,61 @@ export default function LogEntryEditor({
events
])
const currentFingerprint = useMemo(() => {
const payload = buildPayloadForSigning()
return JSON.stringify({
...payload,
signSkipper: fingerprintSignature(signSkipper),
signCrew: fingerprintSignature(signCrew)
})
}, [buildPayloadForSigning, signSkipper, signCrew])
const isDirty = savedFingerprint !== null && currentFingerprint !== savedFingerprint
const persistEntryToDb = useCallback(async (eventsOverride?: LogEvent[]) => {
if (readOnly) return
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const entryData = {
...buildPayloadForSigning(eventsOverride),
signSkipper: normalizedSerializedSignature(signSkipper),
signCrew: normalizedSerializedSignature(signCrew)
}
const encrypted = await encryptJson(entryData, masterKey)
const now = new Date().toISOString()
await db.entries.put({
payloadId: entryId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
await db.syncQueue.put({
action: 'update',
type: 'entry',
payloadId: entryId,
logbookId,
data: JSON.stringify(encrypted),
updatedAt: now
})
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
setSavedFingerprint(JSON.stringify({
...buildPayloadForSigning(eventsOverride),
signSkipper: fingerprintSignature(signSkipper),
signCrew: fingerprintSignature(signCrew)
}))
}, [
readOnly, logbookId, entryId, events, buildPayloadForSigning, signSkipper, signCrew
])
useEffect(() => {
const handleOnline = () => setIsOnline(true)
const handleOffline = () => setIsOnline(false)
@@ -250,14 +346,21 @@ export default function LogEntryEditor({
if (entryHash !== lockedContentHashRef.current) {
lockedContentHashRef.current = null
setSignSkipper('')
setSignCrew('')
void showAlert(
t('logs.sign_cleared_re_sign'),
t('logs.sign_cleared_re_sign_title')
)
const hadSkipper = !!signSkipper
const hadCrew = !!signCrew
const skipperOnly = skipCrewSignClearRef.current
skipCrewSignClearRef.current = false
if (hadSkipper) setSignSkipper('')
if (hadCrew && !skipperOnly) setSignCrew('')
if (lastSignatureAlertHashRef.current !== entryHash && (hadSkipper || (hadCrew && !skipperOnly))) {
lastSignatureAlertHashRef.current = entryHash
void showAlertRef.current(
skipperOnly ? t('logs.sign_cleared_skipper_re_sign') : t('logs.sign_cleared_re_sign'),
skipperOnly ? t('logs.sign_cleared_skipper_re_sign_title') : t('logs.sign_cleared_re_sign_title')
)
}
}
}, [entryHash, signSkipper, signCrew, readOnly, showAlert, t])
}, [entryHash, signSkipper, signCrew, readOnly, t])
const confirmSignWarning = useCallback(async (): Promise<boolean> => {
return showConfirm(
@@ -353,8 +456,10 @@ export default function LogEntryEditor({
async function loadEntry() {
setLoading(true)
setError(null)
setSavedFingerprint(null)
lockedContentHashRef.current = null
contentReadyRef.current = false
lastSignatureAlertHashRef.current = null
try {
if (readOnly && preloadedEntry) {
setDate(preloadedEntry.date || '')
@@ -379,6 +484,7 @@ export default function LogEntryEditor({
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
loadTrackStatsFromEntry(preloadedEntry)
setEvents(preloadedEntry.events || [])
setSavedFingerprint(fingerprintFromStoredEntry(preloadedEntry))
return
}
@@ -411,6 +517,7 @@ export default function LogEntryEditor({
setSignCrew(normalizeSignature(decrypted.signCrew) || '')
loadTrackStatsFromEntry(decrypted)
setEvents(decrypted.events || [])
setSavedFingerprint(fingerprintFromStoredEntry(decrypted))
}
}
} catch (err: any) {
@@ -534,24 +641,19 @@ export default function LogEntryEditor({
return
}
const apiKey = localStorage.getItem('owm_api_key')
if (!apiKey) {
showAlert('GPS capturing failed, and no OpenWeatherMap API key is configured to perform location lookup.')
return
}
try {
const res = await fetch(
`https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(locationQuery)}&appid=${apiKey}&units=metric`
)
if (!res.ok) throw new Error('Location not found')
const data = await res.json()
if (data.coord) {
setEvGpsLat(Number(data.coord.lat).toFixed(6))
setEvGpsLng(Number(data.coord.lon).toFixed(6))
const data = await fetchOpenWeatherCurrent({ q: locationQuery })
const coord = data.coord as { lat?: number; lon?: number } | undefined
if (coord?.lat !== undefined && coord?.lon !== undefined) {
setEvGpsLat(Number(coord.lat).toFixed(6))
setEvGpsLng(Number(coord.lon).toFixed(6))
showAlert(`Coordinates loaded for "${locationQuery}" via OpenWeatherMap.`)
}
} catch (e) {
if (e instanceof WeatherApiError && e.code === 'NO_KEY') {
showAlert(t('settings.no_key'))
return
}
showAlert('Failed to retrieve GPS location or look up coordinates by location name.')
}
}
@@ -590,35 +692,26 @@ export default function LogEntryEditor({
return
}
const apiKey = localStorage.getItem('owm_api_key')
if (!apiKey) {
showAlert(t('settings.no_key'))
return
}
setWeatherLoading(true)
try {
let url = ''
if (hasGps) {
url = `https://api.openweathermap.org/data/2.5/weather?lat=${evGpsLat}&lon=${evGpsLng}&appid=${apiKey}&units=metric`
} else {
url = `https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(fallbackLocation)}&appid=${apiKey}&units=metric`
}
const res = await fetch(url)
if (!res.ok) throw new Error('Weather API rejected the request')
const data = await res.json()
const data = await fetchOpenWeatherCurrent(
hasGps
? { lat: evGpsLat, lon: evGpsLng }
: { q: fallbackLocation }
)
const coord = data.coord as { lat?: number; lon?: number } | undefined
// If fetched by location, automatically pre-fill GPS coordinates
if (!hasGps && data.coord) {
setEvGpsLat(Number(data.coord.lat).toFixed(6))
setEvGpsLng(Number(data.coord.lon).toFixed(6))
if (!hasGps && coord?.lat !== undefined && coord?.lon !== undefined) {
setEvGpsLat(Number(coord.lat).toFixed(6))
setEvGpsLng(Number(coord.lon).toFixed(6))
}
const wind = data.wind as { speed?: number; deg?: number } | undefined
const main = data.main as { pressure?: number } | undefined
// Convert wind speed m/s to Beaufort scale
const mps = data.wind.speed || 0
const mps = wind?.speed || 0
let bft = 0
if (mps < 0.3) bft = 0
else if (mps < 1.6) bft = 1
@@ -635,22 +728,27 @@ export default function LogEntryEditor({
else bft = 12
setEvWindStrength(`${bft} Bft (${mps.toFixed(1)} m/s)`)
setEvWindPressure(String(data.main.pressure || ''))
setEvWindPressure(String(main?.pressure || ''))
// Calculate wind compass direction sector
if (data.wind.deg !== undefined) {
const deg = data.wind.deg
if (wind?.deg !== undefined) {
const deg = wind.deg
const sectors = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW']
const index = Math.round(deg / 22.5) % 16
setEvWindDirection(sectors[index])
}
if (data.weather && data.weather[0]) {
setEvWeatherIcon(data.weather[0].icon)
if (data.weather && Array.isArray(data.weather) && data.weather[0]) {
const first = data.weather[0] as { icon?: string }
if (first.icon) setEvWeatherIcon(first.icon)
}
showAlert(t('settings.weather_success'))
} catch (err) {
if (err instanceof WeatherApiError && err.code === 'NO_KEY') {
showAlert(t('settings.no_key'))
return
}
console.error('Weather prefilling failed:', err)
showAlert(t('settings.weather_error'))
} finally {
@@ -685,32 +783,26 @@ export default function LogEntryEditor({
return currentItems.includes(item.toLowerCase())
}
const handleAddEvent = (e: React.FormEvent) => {
e.preventDefault()
if (readOnly || !evTime) return
const buildEventFromForm = (): LogEvent => ({
time: evTime,
mgk: evMgk.trim(),
rwk: evRwk.trim(),
windPressure: evWindPressure.trim(),
windDirection: evWindDirection.trim(),
windStrength: evWindStrength.trim(),
seaState: evSeaState.trim(),
weatherIcon: evWeatherIcon.trim(),
current: evCurrent.trim(),
heel: evHeel.trim(),
sailsOrMotor: evSailsOrMotor.trim(),
logReading: evLogReading.trim(),
distance: evDistance.trim(),
gpsLat: evGpsLat.trim(),
gpsLng: evGpsLng.trim(),
remarks: evRemarks.trim()
})
const newEvent: LogEvent = {
time: evTime,
mgk: evMgk.trim(),
rwk: evRwk.trim(),
windPressure: evWindPressure.trim(),
windDirection: evWindDirection.trim(),
windStrength: evWindStrength.trim(),
seaState: evSeaState.trim(),
weatherIcon: evWeatherIcon.trim(),
current: evCurrent.trim(),
heel: evHeel.trim(),
sailsOrMotor: evSailsOrMotor.trim(),
logReading: evLogReading.trim(),
distance: evDistance.trim(),
gpsLat: evGpsLat.trim(),
gpsLng: evGpsLng.trim(),
remarks: evRemarks.trim()
}
setEvents((prev) => [...prev, newEvent])
// Clear event form fields
const clearEventForm = () => {
setEvTime('')
setEvMgk('')
setEvRwk('')
@@ -728,11 +820,103 @@ export default function LogEntryEditor({
setEvGpsLng('')
setEvRemarks('')
setEvLocationName('')
setEditingEventIndex(null)
}
const handleDeleteEvent = (index: number) => {
const fillEventForm = (ev: LogEvent) => {
setEvTime(ev.time)
setEvMgk(ev.mgk)
setEvRwk(ev.rwk)
setEvWindPressure(ev.windPressure)
setEvWindDirection(ev.windDirection)
setEvWindStrength(ev.windStrength)
setEvSeaState(ev.seaState)
setEvWeatherIcon(ev.weatherIcon)
setEvCurrent(ev.current)
setEvHeel(ev.heel)
setEvSailsOrMotor(ev.sailsOrMotor)
setEvLogReading(ev.logReading)
setEvDistance(ev.distance)
setEvGpsLat(ev.gpsLat)
setEvGpsLng(ev.gpsLng)
setEvRemarks(ev.remarks)
setEvLocationName('')
}
const markSkipperSignatureClearedForEventChange = () => {
if (!signSkipper) return
skipCrewSignClearRef.current = true
setSignSkipper('')
}
const handleEditEvent = (index: number) => {
if (readOnly) return
setEvents((prev) => prev.filter((_, idx) => idx !== index))
const ev = events[index]
if (!ev) return
fillEventForm(ev)
setEditingEventIndex(index)
}
const handleCancelEventEdit = () => {
clearEventForm()
}
const handleSaveEvent = async (e: React.FormEvent) => {
e.preventDefault()
if (readOnly || !evTime) return
const eventData = buildEventFromForm()
let nextEvents: LogEvent[]
if (editingEventIndex !== null) {
const hadSkipperSignature = !!signSkipper
markSkipperSignatureClearedForEventChange()
nextEvents = events.map((ev, idx) => (idx === editingEventIndex ? eventData : ev))
if (hadSkipperSignature) {
void showAlertRef.current(
t('logs.sign_cleared_skipper_re_sign'),
t('logs.sign_cleared_skipper_re_sign_title')
)
}
} else {
nextEvents = [...events, eventData]
}
setEvents(nextEvents)
clearEventForm()
try {
await persistEntryToDb(nextEvents)
} catch (err: any) {
console.error('Failed to auto-save event:', err)
setError(err.message || 'Failed to save event.')
}
}
const handleDeleteEvent = async (index: number) => {
if (readOnly) return
const hadSkipperSignature = !!signSkipper
markSkipperSignatureClearedForEventChange()
const nextEvents = events.filter((_, idx) => idx !== index)
setEvents(nextEvents)
if (hadSkipperSignature) {
void showAlertRef.current(
t('logs.sign_cleared_skipper_re_sign'),
t('logs.sign_cleared_skipper_re_sign_title')
)
}
if (editingEventIndex === index) {
clearEventForm()
} else if (editingEventIndex !== null && index < editingEventIndex) {
setEditingEventIndex(editingEventIndex - 1)
}
try {
await persistEntryToDb(nextEvents)
} catch (err: any) {
console.error('Failed to auto-save after event delete:', err)
setError(err.message || 'Failed to save event deletion.')
}
}
const handleDownloadPdf = async () => {
@@ -751,45 +935,13 @@ export default function LogEntryEditor({
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (readOnly) return
if (readOnly || !isDirty) return
setSaving(true)
setError(null)
setSuccess(false)
try {
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const entryPayload = buildPayloadForSigning()
const entryData = {
...entryPayload,
signSkipper: serializeSignature(signSkipper),
signCrew: serializeSignature(signCrew)
}
// E2E encrypt
const encrypted = await encryptJson(entryData, masterKey)
const now = new Date().toISOString()
// Save locally
await db.entries.put({
payloadId: entryId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: now
})
// Queue for background sync
await db.syncQueue.put({
action: 'update',
type: 'entry',
payloadId: entryId,
logbookId,
data: JSON.stringify(encrypted),
updatedAt: now
})
await persistEntryToDb()
setSuccess(true)
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
@@ -797,8 +949,6 @@ export default function LogEntryEditor({
setSuccess(false)
onBack()
}, 1500)
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
} catch (err: any) {
console.error('Failed to save entry details:', err)
setError(err.message || 'Failed to save entry details.')
@@ -1072,8 +1222,22 @@ export default function LogEntryEditor({
</td>
<td className="remarks-td">{ev.remarks}</td>
{!readOnly && (
<td>
<button type="button" className="btn-icon logout" onClick={() => handleDeleteEvent(idx)}>
<td className="events-actions-td">
<button
type="button"
className="btn-icon"
onClick={() => handleEditEvent(idx)}
title={t('logs.edit_event')}
disabled={editingEventIndex !== null && editingEventIndex !== idx}
>
<Pencil size={14} />
</button>
<button
type="button"
className="btn-icon logout"
onClick={() => handleDeleteEvent(idx)}
title={t('logs.delete_event')}
>
<Trash2 size={14} />
</button>
</td>
@@ -1088,7 +1252,9 @@ export default function LogEntryEditor({
{/* Add New Event Form Sub-Card */}
{!readOnly && (
<div className="member-editor-card glass">
<h4 style={{ margin: '0 0 16px 0', color: '#fbbf24' }}>{t('logs.add_event')}</h4>
<h4 style={{ margin: '0 0 16px 0', color: '#fbbf24' }}>
{editingEventIndex !== null ? t('logs.edit_event') : t('logs.add_event')}
</h4>
<div className="form-grid mb-4">
<div className="input-group">
@@ -1322,16 +1488,30 @@ export default function LogEntryEditor({
</div>
</div>
<button
type="button"
className="btn secondary"
onClick={handleAddEvent}
disabled={saving || !evTime}
style={{ width: 'auto', padding: '10px 20px', marginLeft: 'auto', display: 'flex' }}
>
<Plus size={16} />
Add Event Entry
</button>
<div style={{ display: 'flex', gap: '8px', marginLeft: 'auto', flexWrap: 'wrap' }}>
{editingEventIndex !== null && (
<button
type="button"
className="btn secondary"
onClick={handleCancelEventEdit}
disabled={saving}
style={{ width: 'auto', padding: '10px 20px', display: 'flex' }}
>
<X size={16} />
{t('logs.cancel_event_edit')}
</button>
)}
<button
type="button"
className="btn secondary"
onClick={handleSaveEvent}
disabled={saving || !evTime}
style={{ width: 'auto', padding: '10px 20px', display: 'flex' }}
>
{editingEventIndex !== null ? <Save size={16} /> : <Plus size={16} />}
{editingEventIndex !== null ? t('logs.save_event_btn') : t('logs.add_event_btn')}
</button>
</div>
</div>
)}
</div>
@@ -1488,7 +1668,7 @@ export default function LogEntryEditor({
</div>
)}
<button type="submit" className="btn primary" disabled={saving || !date || !dayOfTravel.trim()}>
<button type="submit" className="btn primary" disabled={saving || !date || !dayOfTravel.trim() || !isDirty}>
<Save size={18} />
{saving ? t('logs.saving') : t('logs.save')}
</button>
+10 -3
View File
@@ -10,6 +10,7 @@ import { useDialog } from './ModalDialog.tsx'
import AccountDangerZone from './AccountDangerZone.tsx'
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, User, Wifi, WifiOff } from 'lucide-react'
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
interface LogbookDashboardProps {
onSelectLogbook: (id: string, title: string) => void
@@ -205,9 +206,13 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
</div>
{/* Skipper profile */}
<div className="skipper-badge">
<User size={16} />
<span>{username}</span>
<div
className="skipper-badge"
title={t('dashboard.logged_in_as', { name: username })}
aria-label={t('dashboard.logged_in_as', { name: username })}
>
<User size={16} aria-hidden="true" />
<span className="skipper-badge__name">{username}</span>
</div>
{/* Lang toggle */}
@@ -217,6 +222,8 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
<DisclaimerHeaderButton />
<FeedbackHeaderButton />
{/* Logout */}
<button className="btn-icon logout" onClick={handleLogout} title={t('dashboard.logout')}>
<LogOut size={18} />
+20 -10
View File
@@ -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
View File
@@ -5,6 +5,7 @@ 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'
@@ -297,6 +298,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
<form onSubmit={handleSubmit} className="vessel-form mt-6">
<PwaInstallPrompt variant="inline" />
<PushNotificationSettings />
{/* Weather Integration card */}
<div className="member-editor-card glass">
+38 -2
View File
@@ -115,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)",
@@ -236,6 +243,7 @@
"create_btn": "Logbuch erstellen",
"new_logbook_placeholder": "Name des Logbuchs oder der Yacht",
"logout": "Abmelden",
"logged_in_as": "Angemeldet als {{name}}",
"delete_confirm": "Sind Sie sicher, dass Sie dieses Logbuch unwiderruflich löschen möchten? Alle lokalen Daten und Server-Kopien werden vernichtet.\n\nTipp: Erstellen Sie vorher unter Einstellungen → Backup & Wiederherstellung eine Sicherungskopie (.daagbok.json), falls Sie die Daten später behalten möchten.",
"no_logbooks": "Keine Logbücher gefunden. Erstellen Sie Ihr erstes Logbuch, um zu beginnen!",
"loading": "Logbücher werden geladen...",
@@ -293,8 +301,8 @@
"save": "Konfiguration speichern",
"saving": "Wird gespeichert...",
"saved": "Einstellungen erfolgreich gespeichert!",
"key_help": "Ein API-Schlüssel wird benötigt, um Wetterparameter und Seebedingungen automatisch anhand von GPS-Koordinaten abzurufen.",
"no_key": "Bitte hinterlegen Sie Ihren OpenWeatherMap API-Schlüssel in den Einstellungen, um Wetterdaten abzurufen.",
"key_help": "Optional: eigener OpenWeatherMap-API-Schlüssel. Ohne Eintrag wird der serverseitige Schlüssel aus der Betreiber-Konfiguration verwendet.",
"no_key": "Kein OpenWeatherMap-API-Schlüssel verfügbar. Hinterlegen Sie einen eigenen Schlüssel in den Einstellungen oder kontaktieren Sie den Betreiber.",
"weather_success": "Wetterdaten erfolgreich abgerufen!",
"weather_error": "Wetterdatenabruf fehlgeschlagen. Überprüfen Sie den API-Schlüssel und die Verbindung.",
"weather_date_mismatch": "Wetterdaten können nur für den heutigen Tag ({{today}}) abgerufen werden. Dieser Logbucheintrag ist auf den {{date}} datiert.",
@@ -327,6 +335,14 @@
"tour_title": "App-Tour",
"tour_desc": "Lassen Sie sich erneut durch die wichtigsten Bereiche der App führen.",
"tour_restart": "Tour erneut starten",
"push_title": "Push-Benachrichtigungen",
"push_desc": "Als Logbuch-Eigner werden Sie benachrichtigt, wenn eingeladene Crewmitglieder Änderungen synchronisieren. Es werden keine Inhalte im Klartext übermittelt.",
"push_enable": "Bei Crew-Änderungen benachrichtigen",
"push_active": "Push-Benachrichtigungen sind auf diesem Gerät aktiv.",
"push_unsupported": "Push-Benachrichtigungen werden in diesem Browser nicht unterstützt.",
"push_denied_hint": "Benachrichtigungen sind blockiert. Erlauben Sie sie in den Browser- oder Geräteeinstellungen.",
"push_ios_install_hint": "Auf dem iPhone/iPad: App zum Home-Bildschirm hinzufügen (iOS 16.4+), um Push zu nutzen.",
"push_error": "Push-Benachrichtigungen konnten nicht aktiviert werden.",
"backup_title": "Backup & Wiederherstellung",
"backup_desc": "Vollständiges verschlüsseltes Backup dieses Logbuchs (Einträge, Fotos, GPS-Tracks, Crew, Schiff). Mit Backup-Passphrase geschützt — für Restore auf diesem oder einem neuen Account.",
"backup_export_title": "Backup erstellen",
@@ -382,6 +398,26 @@
"close": "Schließen",
"button_title": "Hinweise & Haftungsausschluss"
},
"feedback": {
"button_title": "Feedback senden",
"title": "Feedback",
"intro": "Teilen Sie Fehler, Ideen oder allgemeines Feedback. Ihre Nachricht wird über einen sicheren Benachrichtigungskanal an das Projektteam gesendet.",
"category_label": "Kategorie",
"category_general": "Allgemein",
"category_bug": "Fehler melden",
"category_feature": "Feature-Wunsch",
"contact_label": "E-Mail (optional)",
"contact_placeholder": "ihre@email.beispiel",
"message_label": "Nachricht",
"message_placeholder": "Beschreiben Sie Ihr Feedback…",
"send": "Senden",
"sending": "Wird gesendet…",
"cancel": "Abbrechen",
"success": "Vielen Dank! Ihr Feedback wurde gesendet.",
"error_send": "Feedback konnte nicht gesendet werden. Bitte versuchen Sie es später erneut.",
"error_invalid_email": "Bitte geben Sie eine gültige E-Mail-Adresse ein.",
"error_not_configured": "Feedback ist auf diesem Server nicht verfügbar."
},
"demo": {
"logbook_title": "Demo-Logbuch Ostsee",
"badge": "Demo",
+38 -2
View File
@@ -115,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)",
@@ -236,6 +243,7 @@
"create_btn": "Create Logbook",
"new_logbook_placeholder": "Logbook or Yacht Name",
"logout": "Logout",
"logged_in_as": "Signed in as {{name}}",
"delete_confirm": "Are you sure you want to permanently delete this logbook? All local data and server copies will be destroyed.\n\nTip: Create a backup first under Settings → Backup & restore (.daagbok.json) if you may need the data later.",
"no_logbooks": "No logbooks found. Create your first logbook to begin!",
"loading": "Loading logbooks...",
@@ -293,8 +301,8 @@
"save": "Save Configuration",
"saving": "Saving...",
"saved": "Settings saved successfully!",
"key_help": "An API key is required to automatically fetch real-time weather and sea state parameters based on your vessel's GPS coordinates.",
"no_key": "Please set your OpenWeatherMap API Key in settings to enable weather auto-fill.",
"key_help": "Optional: your own OpenWeatherMap API key. If left empty, the operator-configured server key is used.",
"no_key": "No OpenWeatherMap API key available. Add your own key in settings or contact the operator.",
"weather_success": "Weather details fetched successfully!",
"weather_error": "Failed to fetch weather. Check your API key and connection.",
"weather_date_mismatch": "Weather data can only be fetched for today ({{today}}). This logbook entry is dated {{date}}.",
@@ -327,6 +335,14 @@
"tour_title": "App tour",
"tour_desc": "Take a guided walkthrough of the main areas of the app again.",
"tour_restart": "Restart tour",
"push_title": "Push notifications",
"push_desc": "As logbook owner you are notified when invited crew members sync changes. No logbook content is sent in plain text.",
"push_enable": "Notify on crew changes",
"push_active": "Push notifications are active on this device.",
"push_unsupported": "Push notifications are not supported in this browser.",
"push_denied_hint": "Notifications are blocked. Allow them in your browser or device settings.",
"push_ios_install_hint": "On iPhone/iPad: add the app to your Home Screen (iOS 16.4+) to use push notifications.",
"push_error": "Could not enable push notifications.",
"backup_title": "Backup & restore",
"backup_desc": "Full encrypted backup of this logbook (entries, photos, GPS tracks, crew, vessel). Protected with a backup passphrase — restore on this or a new account.",
"backup_export_title": "Create backup",
@@ -382,6 +398,26 @@
"close": "Close",
"button_title": "Legal notice & disclaimer"
},
"feedback": {
"button_title": "Send feedback",
"title": "Feedback",
"intro": "Share bugs, ideas or general feedback. Your message is sent to the project team via a secure notification channel.",
"category_label": "Category",
"category_general": "General",
"category_bug": "Bug report",
"category_feature": "Feature request",
"contact_label": "Email (optional)",
"contact_placeholder": "your@email.example",
"message_label": "Message",
"message_placeholder": "Describe your feedback…",
"send": "Send",
"sending": "Sending…",
"cancel": "Cancel",
"success": "Thank you! Your feedback has been sent.",
"error_send": "Could not send feedback. Please try again later.",
"error_invalid_email": "Please enter a valid email address.",
"error_not_configured": "Feedback is not available on this server."
},
"demo": {
"logbook_title": "Baltic Sea Demo Logbook",
"badge": "Demo",
+4 -1
View File
@@ -20,7 +20,10 @@ export const PlausibleEvents = {
PHOTO_UPLOADED: 'Photo Uploaded',
BACKUP_EXPORTED: 'Backup Exported',
BACKUP_RESTORED: 'Backup Restored',
DEMO_OPENED: 'Demo Opened'
DEMO_OPENED: 'Demo Opened',
PUSH_ENABLED: 'Push Enabled',
PUSH_DISABLED: 'Push Disabled',
FOOTER_LINK_CLICKED: 'Footer Link Clicked'
} as const
export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents]
+66
View File
@@ -0,0 +1,66 @@
export type FeedbackCategory = 'bug' | 'feature' | 'general'
export class FeedbackApiError extends Error {
code: 'NOT_CONFIGURED' | 'REQUEST_FAILED' | 'INVALID_EMAIL'
constructor(
message: string,
code: 'NOT_CONFIGURED' | 'REQUEST_FAILED' | 'INVALID_EMAIL' = 'REQUEST_FAILED'
) {
super(message)
this.name = 'FeedbackApiError'
this.code = code
}
}
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
export function isValidFeedbackEmail(email: string): boolean {
return EMAIL_PATTERN.test(email.trim())
}
function buildFeedbackHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json'
}
const userId = localStorage.getItem('active_userid')
if (userId) headers['X-User-Id'] = userId
return headers
}
export async function sendFeedback(payload: {
category: FeedbackCategory
message: string
contactEmail?: string | null
logbookId?: string | null
logbookTitle?: string | null
}): Promise<void> {
const contactEmail = payload.contactEmail?.trim()
if (contactEmail && !isValidFeedbackEmail(contactEmail)) {
throw new FeedbackApiError('Invalid email address', 'INVALID_EMAIL')
}
const res = await fetch('/api/feedback', {
method: 'POST',
headers: buildFeedbackHeaders(),
body: JSON.stringify({
category: payload.category,
message: payload.message,
contactEmail: contactEmail || undefined,
username: localStorage.getItem('active_username') || undefined,
logbookId: payload.logbookId || undefined,
logbookTitle: payload.logbookTitle || undefined,
appVersion: typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : undefined,
pageUrl: window.location.href
})
})
if (res.status === 503) {
throw new FeedbackApiError('Feedback is not configured on this server', 'NOT_CONFIGURED')
}
const data = await res.json().catch(() => ({}))
if (!res.ok) {
throw new FeedbackApiError(data.error || 'Failed to send feedback')
}
}
+182
View File
@@ -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()
}
+52
View File
@@ -0,0 +1,52 @@
export class WeatherApiError extends Error {
code: 'NO_KEY' | 'REQUEST_FAILED'
constructor(message: string, code: 'NO_KEY' | 'REQUEST_FAILED' = 'REQUEST_FAILED') {
super(message)
this.name = 'WeatherApiError'
this.code = code
}
}
function buildWeatherHeaders(): Record<string, string> {
const headers: Record<string, string> = {}
const userId = localStorage.getItem('active_userid')
const userKey = localStorage.getItem('owm_api_key')?.trim()
if (userId) headers['X-User-Id'] = userId
if (userKey) headers['X-OWM-Api-Key'] = userKey
return headers
}
export async function fetchOpenWeatherCurrent(params: {
lat?: string
lon?: string
q?: string
}): Promise<Record<string, unknown>> {
const searchParams = new URLSearchParams()
if (params.lat && params.lon) {
searchParams.set('lat', params.lat)
searchParams.set('lon', params.lon)
} else if (params.q?.trim()) {
searchParams.set('q', params.q.trim())
} else {
throw new WeatherApiError('lat/lon or location query required')
}
const res = await fetch(`/api/weather/current?${searchParams.toString()}`, {
headers: buildWeatherHeaders()
})
if (res.status === 503) {
throw new WeatherApiError('No OpenWeatherMap API key configured', 'NO_KEY')
}
const data = await res.json()
if (!res.ok) {
throw new WeatherApiError('Weather API rejected the request')
}
return data
}
+73
View File
@@ -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)
})()
)
})
+9
View File
@@ -68,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) ?? ''
}
+9
View File
@@ -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
+5 -3
View File
@@ -38,16 +38,18 @@ export default defineConfig({
plugins: [
react(),
VitePWA({
strategies: 'injectManifest',
srcDir: 'src',
filename: 'sw.ts',
registerType: 'prompt',
includeAssets: ['favicon.ico', 'logo.png'],
workbox: {
cleanupOutdatedCaches: true,
injectManifest: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2,webmanifest}']
},
manifest: {
name: 'Kapteins Daagbok',
short_name: 'Daagbok',
description: 'Digital maritime ship logbook with E2E encryption and Passkeys',
description: 'Free, ad-free maritime logbook with E2E encryption and Passkeys',
theme_color: '#1e293b',
background_color: '#0f172a',
display: 'standalone',
+7
View File
@@ -26,6 +26,13 @@ services:
DATABASE_URL: "postgresql://postgres:postgres@db:5432/daagbox?schema=public"
RP_ID: ${RP_ID:-localhost}
ORIGIN: ${ORIGIN:-http://localhost}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:support@kapteins-daagbok.eu}
OpenWeatherMapAPIKey: ${OpenWeatherMapAPIKey:-}
NTFY_SERVER: ${NTFY_SERVER:-https://ntfy.sh}
NTFY_TOPIC: ${NTFY_TOPIC:-}
NTFY_TOKEN: ${NTFY_TOKEN:-}
command: sh -c "npx prisma db push && node dist/index.js"
depends_on:
db:
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

+290
View File
@@ -0,0 +1,290 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<title>Kapteins Daagbok — Beta-Flyer</title>
<style>
@page {
size: A4 portrait;
margin: 0;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
width: 210mm;
height: 297mm;
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
color: #e2e8f0;
background: #0f172a;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.page {
width: 210mm;
height: 297mm;
padding: 14mm 16mm 12mm;
display: flex;
flex-direction: column;
background:
radial-gradient(ellipse 120% 80% at 100% 0%, rgba(56, 189, 248, 0.12) 0%, transparent 55%),
radial-gradient(ellipse 90% 60% at 0% 100%, rgba(134, 59, 255, 0.14) 0%, transparent 50%),
linear-gradient(165deg, #0f172a 0%, #1e293b 45%, #0f172a 100%);
position: relative;
overflow: hidden;
}
.page::before {
content: "";
position: absolute;
inset: 8mm;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 4mm;
pointer-events: none;
}
header {
display: flex;
align-items: center;
gap: 5mm;
margin-bottom: 6mm;
position: relative;
z-index: 1;
}
.logo {
width: 14mm;
height: 14mm;
flex-shrink: 0;
}
.title-block h1 {
font-size: 22pt;
font-weight: 700;
letter-spacing: -0.02em;
color: #f8fafc;
line-height: 1.1;
}
.title-block p {
font-size: 10.5pt;
color: #94a3b8;
margin-top: 1.5mm;
}
.badge {
margin-left: auto;
align-self: flex-start;
background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
color: #1e293b;
font-size: 9pt;
font-weight: 800;
letter-spacing: 0.12em;
padding: 2mm 4mm;
border-radius: 2mm;
text-transform: uppercase;
}
.intro {
font-size: 10.5pt;
line-height: 1.55;
color: #cbd5e1;
margin-bottom: 6mm;
max-width: 95%;
position: relative;
z-index: 1;
}
.intro strong {
color: #f8fafc;
}
.features {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 3mm 6mm;
margin-bottom: 6mm;
position: relative;
z-index: 1;
}
.feature {
display: flex;
gap: 2.5mm;
align-items: flex-start;
font-size: 9.5pt;
line-height: 1.4;
color: #e2e8f0;
}
.feature-icon {
color: #38bdf8;
font-weight: 700;
flex-shrink: 0;
width: 4mm;
}
.beta-box {
background: rgba(30, 41, 59, 0.85);
border: 1px solid rgba(251, 191, 36, 0.35);
border-left: 3px solid #fbbf24;
border-radius: 3mm;
padding: 5mm 6mm;
margin-bottom: 6mm;
position: relative;
z-index: 1;
}
.beta-box h2 {
font-size: 11pt;
color: #fbbf24;
margin-bottom: 2mm;
font-weight: 700;
}
.beta-box p {
font-size: 9.5pt;
line-height: 1.5;
color: #cbd5e1;
}
.cta {
display: flex;
align-items: center;
gap: 8mm;
background: rgba(15, 23, 42, 0.6);
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 4mm;
padding: 5mm 6mm;
margin-bottom: auto;
position: relative;
z-index: 1;
}
.qr {
width: 32mm;
height: 32mm;
background: #fff;
padding: 2mm;
border-radius: 2mm;
flex-shrink: 0;
}
.qr img {
width: 100%;
height: 100%;
display: block;
}
.cta-text h3 {
font-size: 13pt;
color: #38bdf8;
font-weight: 700;
margin-bottom: 2mm;
}
.cta-text p {
font-size: 9pt;
color: #94a3b8;
line-height: 1.45;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 2mm;
margin-top: 3mm;
}
.tag {
font-size: 7.5pt;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #64748b;
border: 1px solid rgba(100, 116, 139, 0.4);
border-radius: 1.5mm;
padding: 1mm 2.5mm;
}
footer {
border-top: 1px solid rgba(148, 163, 184, 0.15);
padding-top: 3mm;
margin-top: 5mm;
font-size: 7.5pt;
line-height: 1.5;
color: #64748b;
position: relative;
z-index: 1;
}
footer strong {
color: #94a3b8;
font-weight: 600;
}
</style>
</head>
<body>
<article class="page">
<header>
<img class="logo" src="../../client/public/favicon.svg" alt="" />
<div class="title-block">
<h1>Kapteins Daagbok</h1>
<p>Digitales Yacht-Logbuch — kostenlos &amp; werbefrei</p>
</div>
<span class="badge">Beta</span>
</header>
<p class="intro">
Führen Sie Ihr Bordlogbuch digital: Reisetage, GPS-Tracks, Crew und Schiffsdaten —
<strong>End-to-End-verschlüsselt</strong>, als App installierbar und
<strong>auch offline</strong> auf See nutzbar.
</p>
<section class="features" aria-label="Funktionen">
<div class="feature"><span class="feature-icon"></span><span>Reisetage im nautischen Logbuch-Format (Hafen, Wetter, Tankstände)</span></div>
<div class="feature"><span class="feature-icon"></span><span>Offline-fähige PWA — installierbar auf Smartphone &amp; Tablet</span></div>
<div class="feature"><span class="feature-icon"></span><span>Passkey-Anmeldung &amp; clientseitige Verschlüsselung</span></div>
<div class="feature"><span class="feature-icon"></span><span>GPS-Tracks (GPX/KML), Karte &amp; Streckenstatistik</span></div>
<div class="feature"><span class="feature-icon"></span><span>Foto-Anhänge pro Reisetag</span></div>
<div class="feature"><span class="feature-icon"></span><span>Crew einladen — gemeinsam am Logbuch arbeiten</span></div>
<div class="feature"><span class="feature-icon"></span><span>PDF- &amp; CSV-Export, verschlüsseltes Backup</span></div>
<div class="feature"><span class="feature-icon"></span><span>Mehrere Logbücher · Deutsch &amp; Englisch</span></div>
</section>
<section class="beta-box">
<h2>Beta-Phase — Ihr Feedback zählt</h2>
<p>
Kapteins Daagbok ist ein <strong>privates Hobbyprojekt ohne Gewinnabsicht</strong>.
Als Beta-Tester helfen Sie, die App für Skipper und Crew im Alltag zu verbessern —
Rückmeldungen sind ausdrücklich willkommen.
</p>
</section>
<section class="cta">
<div class="qr">
<img src="assets/qr-kapteins-daagbok.eu.png" alt="QR-Code: kapteins-daagbok.eu" />
</div>
<div class="cta-text">
<h3>kapteins-daagbok.eu</h3>
<p>Im Browser öffnen oder als App zum Home-Bildschirm hinzufügen. Registrierung mit Passkey — kein App-Store nötig.</p>
<div class="tags">
<span class="tag">Kostenlos</span>
<span class="tag">Werbefrei</span>
<span class="tag">E2E-verschlüsselt</span>
</div>
</div>
</section>
<footer>
<strong>Impressum</strong><br />
KnorrLabs · Markus F.J. Busche · Knorrstr. 16 · 24106 Kiel · elpatron+kd@mailbox.org
</footer>
</article>
</body>
</html>
Binary file not shown.
+3
View File
@@ -35,6 +35,9 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
| Photo Uploaded | Foto hochgeladen (`PhotoCapture.tsx`, `CrewForm.tsx`) | `context`: `logbook` \| `crew`, bei Crew zusätzlich `role`: `skipper` \| `crew` |
| Backup Exported | Backup-Datei heruntergeladen (`LogbookBackupPanel.tsx`) | `entries`, `photos` (Anzahlen, keine Inhalte) |
| Backup Restored | Backup wiederhergestellt (`LogbookBackupPanel.tsx`) | `entries`, `photos`, `mode`: `same_id` \| `overwrite` \| `new_id` |
| Push Enabled | Crew-Änderungs-Push aktiviert (`PushNotificationSettings.tsx`) | — |
| Push Disabled | Crew-Änderungs-Push deaktiviert (`PushNotificationSettings.tsx`) | — |
| Footer Link Clicked | Klick auf Autoren-Link im App-Footer (`AppFooter.tsx`) | — |
## Bewusst nicht getrackt
+417
View File
@@ -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 25 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 (12 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 (12 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:** 46 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
```
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env node
/**
* Generates the beta flyer PDF from docs/marketing/beta-flyer.html
* Usage: npm run generate:flyer --prefix client
*/
import { execSync } from 'node:child_process'
import { mkdir, writeFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { createRequire } from 'node:module'
const __dirname = dirname(fileURLToPath(import.meta.url))
const repoRoot = resolve(__dirname, '..')
const clientDir = resolve(repoRoot, 'client')
const marketingDir = resolve(repoRoot, 'docs/marketing')
const assetsDir = resolve(marketingDir, 'assets')
const htmlPath = resolve(marketingDir, 'beta-flyer.html')
const qrPath = resolve(assetsDir, 'qr-kapteins-daagbok.eu.png')
const pdfPath = resolve(marketingDir, 'kapteins-daagbok-beta-flyer.pdf')
const appUrl = 'https://kapteins-daagbok.eu'
const require = createRequire(resolve(clientDir, 'package.json'))
function isMissingBrowserError(err) {
const msg = err instanceof Error ? err.message : String(err)
return msg.includes("Executable doesn't exist") || msg.includes('browserType.launch')
}
async function ensurePlaywrightChromium(playwright) {
try {
const browser = await playwright.chromium.launch({ headless: true })
await browser.close()
return
} catch (err) {
if (!isMissingBrowserError(err)) throw err
}
console.log('Playwright Chromium fehlt — installiere Browser (einmalig)…')
execSync('npx playwright install chromium', {
cwd: clientDir,
stdio: 'inherit'
})
}
async function ensureQrCode() {
let QRCode
try {
QRCode = require('qrcode')
} catch {
console.error('Fehlende Abhängigkeit: "npm install -D qrcode playwright" in client/ ausführen.')
process.exit(1)
}
await mkdir(assetsDir, { recursive: true })
const png = await QRCode.toBuffer(appUrl, {
type: 'png',
width: 512,
margin: 1,
color: { dark: '#0f172a', light: '#ffffff' }
})
await writeFile(qrPath, png)
console.log('QR code written:', qrPath)
}
async function renderPdf() {
let playwright
try {
playwright = require('playwright')
} catch {
console.error('Fehlende Abhängigkeit: "npm install -D playwright" in client/ ausführen.')
process.exit(1)
}
await ensurePlaywrightChromium(playwright)
const browser = await playwright.chromium.launch({ headless: true })
try {
const page = await browser.newPage()
await page.goto(pathToFileURL(htmlPath).href, { waitUntil: 'networkidle' })
await page.pdf({
path: pdfPath,
format: 'A4',
printBackground: true,
preferCSSPageSize: true,
margin: { top: 0, right: 0, bottom: 0, left: 0 }
})
console.log('PDF written:', pdfPath)
} finally {
await browser.close()
}
}
await ensureQrCode()
await renderPdf()
+80 -4
View File
@@ -3,12 +3,66 @@
# Configuration
SERVER_PORT=5000
CLIENT_PORT=5173
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
resolve_node_toolchain() {
# Common install locations when login shell PATH is not loaded
if command -v npm >/dev/null 2>&1; then
return 0
fi
if [ -s "$HOME/.nvm/nvm.sh" ]; then
# shellcheck disable=SC1090
. "$HOME/.nvm/nvm.sh"
elif [ -s "/usr/local/nvm/nvm.sh" ]; then
# shellcheck disable=SC1090
. "/usr/local/nvm/nvm.sh"
fi
if [ -d "$HOME/.fnm" ] && command -v fnm >/dev/null 2>&1; then
eval "$(fnm env)"
fi
for candidate in \
/usr/local/bin/npm \
/usr/bin/npm \
"$HOME/.local/share/fnm/current/bin/npm" \
"$HOME/.nvm/versions/node/"*/bin/npm; do
if [ -x "$candidate" ]; then
export PATH="$(dirname "$candidate"):$PATH"
break
fi
done
command -v npm >/dev/null 2>&1
}
require_node_toolchain() {
if resolve_node_toolchain; then
echo "Using Node $(node -v), npm $(npm -v)"
return 0
fi
echo "Error: npm was not found in PATH."
echo ""
echo "This script starts local Vite/Express dev servers and requires Node.js 20+ with npm."
echo "Install Node.js on this machine, or use the Docker-based stack instead:"
echo " ./scripts/start-dev-docker.sh"
echo ""
echo "On the production host, prefer updating the running stack:"
echo " docker compose -f docker-compose.yml up -d --build"
echo " # or from your workstation: ./scripts/update-prod.sh"
exit 1
}
echo "========================================"
echo " Kapteins Daagbok Dev Environment "
echo "========================================"
echo "Preparing to (re)start services..."
require_node_toolchain
# Clean up processes running on ports
cleanup_port() {
local port=$1
@@ -77,18 +131,40 @@ fi
# Start backend server
echo "Starting backend API server..."
cd server
cd "$REPO_ROOT/server" || exit 1
if [ ! -d node_modules ]; then
echo "Error: server/node_modules missing. Run: cd server && npm ci"
exit 1
fi
npm run dev &
cd ..
BACKEND_PID=$!
cd "$REPO_ROOT" || exit 1
# Sleep briefly to let server start up
sleep 1.5
if ! kill -0 "$BACKEND_PID" 2>/dev/null; then
echo "Error: Backend dev server exited immediately. Check server logs above."
exit 1
fi
# Start frontend client
echo "Starting frontend dev server..."
cd client
cd "$REPO_ROOT/client" || exit 1
if [ ! -d node_modules ]; then
echo "Error: client/node_modules missing. Run: cd client && npm ci"
kill "$BACKEND_PID" 2>/dev/null
exit 1
fi
npm run dev &
cd ..
CLIENT_PID=$!
cd "$REPO_ROOT" || exit 1
sleep 1.5
if ! kill -0 "$CLIENT_PID" 2>/dev/null; then
echo "Error: Frontend dev server exited immediately. Check client logs above."
kill "$BACKEND_PID" 2>/dev/null
exit 1
fi
echo "========================================"
echo "Dev services are now running:"
+155 -1
View File
@@ -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
View File
@@ -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"
}
+26
View File
@@ -20,6 +20,32 @@ model User {
credentials Credential[]
logbooks Logbook[]
collaborations Collaboration[]
pushSubscriptions PushSubscription[]
notificationPrefs UserNotificationPrefs?
}
model PushSubscription {
id String @id @default(uuid())
userId String
endpoint String @unique
p256dh String
auth String
userAgent String?
locale String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}
model UserNotificationPrefs {
userId String @id
collaboratorChangesEnabled Boolean @default(false)
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Credential {
+12 -1
View File
@@ -1,14 +1,22 @@
import express from 'express'
import cors from 'cors'
import dotenv from 'dotenv'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import authRouter from './routes/auth.js'
import logbooksRouter from './routes/logbooks.js'
import syncRouter from './routes/sync.js'
import collaborationRouter from './routes/collaboration.js'
import signRouter from './routes/sign.js'
import pushRouter from './routes/push.js'
import weatherRouter from './routes/weather.js'
import feedbackRouter from './routes/feedback.js'
import { prisma } from './db.js'
dotenv.config()
const __dirname = dirname(fileURLToPath(import.meta.url))
dotenv.config({ path: resolve(__dirname, '../../.env') })
dotenv.config({ path: resolve(__dirname, '../.env') })
const app = express()
const PORT = process.env.PORT || 5000
@@ -22,6 +30,9 @@ app.use('/api/logbooks', logbooksRouter)
app.use('/api/sync', syncRouter)
app.use('/api/collaboration', collaborationRouter)
app.use('/api/sign', signRouter)
app.use('/api/push', pushRouter)
app.use('/api/weather', weatherRouter)
app.use('/api/feedback', feedbackRouter)
// Health check endpoint
app.get('/api/health', async (req, res) => {
+85
View File
@@ -0,0 +1,85 @@
import { Router } from 'express'
import { isNtfyConfigured, sendFeedbackViaNtfy } from '../services/ntfyNotify.js'
const router = Router()
const VALID_CATEGORIES = new Set(['bug', 'feature', 'general'])
const MAX_MESSAGE_LENGTH = 2000
const MAX_EMAIL_LENGTH = 254
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
function parseOptionalEmail(value: unknown): string | undefined {
if (value === undefined || value === null || value === '') return undefined
if (typeof value !== 'string') return undefined
const trimmed = value.trim()
if (!trimmed) return undefined
if (trimmed.length > MAX_EMAIL_LENGTH) return undefined
if (!EMAIL_PATTERN.test(trimmed)) return undefined
return trimmed
}
const requireUser = (req: any, res: any, next: any) => {
const userId = req.headers['x-user-id']
if (!userId) {
return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' })
}
req.userId = userId
next()
}
router.get('/status', requireUser, (_req, res) => {
res.json({ enabled: isNtfyConfigured() })
})
router.post('/', requireUser, async (req: any, res) => {
try {
if (!isNtfyConfigured()) {
return res.status(503).json({ error: 'Feedback is not configured on this server' })
}
const { category, message, username, contactEmail, logbookId, logbookTitle, appVersion, pageUrl } =
req.body ?? {}
if (typeof category !== 'string' || !VALID_CATEGORIES.has(category)) {
return res.status(400).json({ error: 'Invalid category' })
}
if (typeof message !== 'string' || !message.trim()) {
return res.status(400).json({ error: 'Message is required' })
}
const trimmedMessage = message.trim()
if (trimmedMessage.length > MAX_MESSAGE_LENGTH) {
return res.status(400).json({ error: `Message must be at most ${MAX_MESSAGE_LENGTH} characters` })
}
let parsedContactEmail: string | undefined
if (contactEmail !== undefined && contactEmail !== null && String(contactEmail).trim()) {
parsedContactEmail = parseOptionalEmail(contactEmail)
if (!parsedContactEmail) {
return res.status(400).json({ error: 'Invalid email address' })
}
}
await sendFeedbackViaNtfy({
category,
message: trimmedMessage,
username: typeof username === 'string' ? username.trim() : undefined,
contactEmail: parsedContactEmail,
userId: req.userId,
logbookId: typeof logbookId === 'string' ? logbookId.trim() : undefined,
logbookTitle: typeof logbookTitle === 'string' ? logbookTitle.trim() : undefined,
appVersion: typeof appVersion === 'string' ? appVersion.trim() : undefined,
pageUrl: typeof pageUrl === 'string' ? pageUrl.trim() : undefined
})
return res.json({ ok: true })
} catch (error: any) {
console.error('Error sending feedback via Ntfy:', error)
return res.status(502).json({ error: error.message || 'Failed to send feedback' })
}
})
export default router
+139
View File
@@ -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
+34
View File
@@ -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)
+59
View File
@@ -0,0 +1,59 @@
import { Router } from 'express'
const router = Router()
const requireUser = (req: any, res: any, next: any) => {
const userId = req.headers['x-user-id']
if (!userId) {
return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' })
}
req.userId = userId
next()
}
function resolveOwmApiKey(userProvidedKey: unknown): string | null {
if (typeof userProvidedKey === 'string' && userProvidedKey.trim()) {
return userProvidedKey.trim()
}
const fromEnv =
process.env.OpenWeatherMapAPIKey?.trim() ||
process.env.OPENWEATHERMAP_API_KEY?.trim()
return fromEnv || null
}
router.get('/current', requireUser, async (req: any, res) => {
try {
const { lat, lon, q } = req.query
const apiKey = resolveOwmApiKey(req.headers['x-owm-api-key'])
if (!apiKey) {
return res.status(503).json({
error: 'No OpenWeatherMap API key configured (user settings or server environment)'
})
}
let url: URL
if (lat && lon) {
url = new URL('https://api.openweathermap.org/data/2.5/weather')
url.searchParams.set('lat', String(lat))
url.searchParams.set('lon', String(lon))
} else if (q && typeof q === 'string' && q.trim()) {
url = new URL('https://api.openweathermap.org/data/2.5/weather')
url.searchParams.set('q', q.trim())
} else {
return res.status(400).json({ error: 'lat and lon, or q (location name) is required' })
}
url.searchParams.set('appid', apiKey)
url.searchParams.set('units', 'metric')
const owmRes = await fetch(url)
const data = await owmRes.json()
return res.status(owmRes.status).json(data)
} catch (error: any) {
console.error('Error fetching OpenWeatherMap data:', error)
return res.status(502).json({ error: error.message || 'Weather lookup failed' })
}
})
export default router
+79
View File
@@ -0,0 +1,79 @@
export interface FeedbackPayload {
category: string
message: string
username?: string
contactEmail?: string
userId: string
logbookId?: string
logbookTitle?: string
appVersion?: string
pageUrl?: string
}
function resolveNtfyConfig(): { server: string; topic: string; token?: string } | null {
const server = (process.env.NTFY_SERVER || 'https://ntfy.sh').replace(/\/+$/, '')
const topic = process.env.NTFY_TOPIC?.trim()
const token = process.env.NTFY_TOKEN?.trim()
if (!topic) return null
return { server, topic, token: token || undefined }
}
export function isNtfyConfigured(): boolean {
return resolveNtfyConfig() !== null
}
export async function sendFeedbackViaNtfy(payload: FeedbackPayload): Promise<void> {
const config = resolveNtfyConfig()
if (!config) {
throw new Error('NTFY_TOPIC is not configured')
}
const categoryLabel = payload.category.charAt(0).toUpperCase() + payload.category.slice(1)
const title = `Kapteins Daagbok - ${categoryLabel}`
const lines = [
payload.message,
'',
'---',
`User: ${payload.username || '(unknown)'}`,
`User ID: ${payload.userId}`
]
if (payload.contactEmail) {
lines.push(`Contact: ${payload.contactEmail}`)
}
if (payload.logbookTitle || payload.logbookId) {
lines.push(`Logbook: ${payload.logbookTitle || payload.logbookId}`)
}
if (payload.appVersion) {
lines.push(`App version: ${payload.appVersion}`)
}
if (payload.pageUrl) {
lines.push(`Page: ${payload.pageUrl}`)
}
const headers: Record<string, string> = {
Title: title,
Tags: 'speech_balloon,ship',
'Content-Type': 'text/plain; charset=utf-8'
}
if (config.token) {
headers.Authorization = `Bearer ${config.token}`
}
const url = `${config.server}/${encodeURIComponent(config.topic)}`
const res = await fetch(url, {
method: 'POST',
headers,
body: lines.join('\n')
})
if (!res.ok) {
const body = await res.text().catch(() => '')
throw new Error(`Ntfy request failed (${res.status})${body ? `: ${body}` : ''}`)
}
}
+105
View File
@@ -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)
}
}
})
)
}