Compare commits

..

27 Commits

Author SHA1 Message Date
elpatron 2d64987ada chore: release v0.1.0.56 2026-05-31 12:38:09 +02:00
elpatron 87973eaa4a fix: Light-Theme-Hintergrund auf PWA/Android reparieren
Der hardcodierte Inline-Style auf body überschrieb --app-body-bg und ließ
hellen Modus mit dunklem Seitenhintergrund erscheinen. Theme-Bootstrap und
dynamisches theme-color ergänzen alle Scheme/Theme-Kombinationen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 12:38:01 +02:00
elpatron 93e26b7807 chore: release v0.1.0.55 2026-05-31 12:26:55 +02:00
elpatron 814eeadd1f fix: Sync-Indikator Listener-Cleanup und CSS-Zustände
useSyncIndicator gibt die Unsubscribe-Funktion von subscribeToSyncState
zurück. conn-status-Klassen berücksichtigen jetzt auch den aktiven
Sync-Lauf (syncing) statt nur die Queue-Länge.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 12:26:33 +02:00
elpatron d9cbcd8e43 chore: release v0.1.0.54 2026-05-31 12:24:01 +02:00
elpatron 282e7ba8ba fix: Sync-Icon nur während aktiver Synchronisation animieren
Die Drehung hing an der Queue-Länge statt am laufenden Sync. Veraltete
Queue-Einträge werden nach Pull bereinigt; parallele syncAll-Läufe
werden im Sync-State korrekt gezählt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 12:23:50 +02:00
elpatron b86e5a15d6 chore: release v0.1.0.53 2026-05-31 12:16:02 +02:00
elpatron eac86ec655 README: Neue Features und klare Trennung Profil vs. Logbuch.
Dokumentiert Kompass-Dial, Benutzerprofil, Feedback/Ntfy, Demo-URL, Tests und aktualisierte Env-Variablen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 12:15:46 +02:00
elpatron a6331bea1a OWM-API-Schlüssel explizit über aktive User-ID laden.
Wetter-Abruf nutzt getOwmApiKeyForActiveUser(), damit namespaced Keys nicht am fehlenden active_userid vorbeilaufen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 12:14:43 +02:00
elpatron ae89b131a1 chore: release v0.1.0.52 2026-05-31 12:12:15 +02:00
elpatron 3fdea31c4a Update beta flyer styles: Adjust feature gap to 1.8mm and line height to 1.28 for improved layout consistency. Update PDF file to reflect changes. 2026-05-31 12:09:34 +02:00
elpatron 04d114c315 Marketing-Flyer: Themes mit Hell/Dunkel-Varianten erwähnen.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 12:07:37 +02:00
elpatron 3fa66f044c Korrigiere Label-Ausrichtung im Backup-Panel.
Labels waren durch text-align:center auf #root zentriert, Inputs linksbündig. Formularfelder nutzen nun block-Labels und konsistenten Abstand.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 12:06:18 +02:00
elpatron a84c611402 Verschiebe benutzerbezogene Einstellungen ins Benutzerprofil.
Theme, Farbschema, OWM-Schlüssel, Push, PWA und App-Tour liegen nun im Profil mit pro-User-localStorage. Der Logbuch-Tab fokussiert Teilen, Backup und Crew.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 12:01:08 +02:00
elpatron f12b9b2a1a chore: release v0.1.0.51 2026-05-31 11:52:20 +02:00
elpatron 34914b4f19 fix(ui): Segel-Picker-Layout und Formular-Reset korrigieren
Flex-Layout für Pills und Toggle wiederherstellen und den Einklapp-Zustand beim Leeren des Ereignisformulars zurücksetzen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 11:52:10 +02:00
elpatron d9fa8c0edf chore: release v0.1.0.50 2026-05-31 11:50:39 +02:00
elpatron adf02acd45 fix(ui): Segel-Picker auf Mobile weiter verdichten
Einklappbare Badge-Liste bei vielen Segeln, kompaktere Pills und aktive Auswahl bleibt oben sichtbar.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 11:50:23 +02:00
elpatron 3992db9d61 fix(ui): Besegelungs-Badges auf Mobile platzsparender anordnen
Die Segel-Pills nutzen die volle Formularbreite und wrappen kompakt, statt in der halben Grid-Spalte untereinander zu stapeln.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 11:48:30 +02:00
elpatron 51f6a1b291 chore: release v0.1.0.49 2026-05-31 11:42:37 +02:00
elpatron 0b07d8b3d3 feat(auth): Hilfe-Button auf Login öffnet Hinweise-Modal
Ersetzt den toten #help-Link durch einen Button, der dasselbe
Hinweise- und Haftungsausschluss-Modal wie in der App anzeigt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 11:42:28 +02:00
elpatron a07e033e62 copy(i18n): Login-Tagline persönlicher formulieren
„Dein sicheres …“ statt „Sicheres …“ auf der Anmeldeseite.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 11:40:06 +02:00
elpatron bbe63dfb47 chore: release v0.1.0.48 2026-05-31 11:38:03 +02:00
elpatron 57f63ad486 fix(auth): Session-Restore erst mit vollständiger lokaler Session
Stellt hasUnlockedLocalSession für UI-Wiederherstellung und
enforceUnlockedSession wieder her; persistSessionUserId setzt userId
nur bei Angabe in der Server-Antwort.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 11:37:50 +02:00
elpatron 728c40f936 chore: release v0.1.0.47 2026-05-31 11:35:51 +02:00
elpatron 72cbad8d5e fix(auth): Session-Wiederherstellung nicht an active_userid koppeln
Trennt hasUnlockedLocalCrypto (Master-Key + Username) von
hasUnlockedLocalSession (+ userId für API), damit ein gültiges
Server-Cookie ohne userId in der Antwort keinen fälschlichen Logout auslöst.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 11:35:17 +02:00
elpatron 15f2172a38 fix: ungenutzten getActiveMasterKey-Import entfernen
Behebt TS6133 und schlägt fehlenden Docker-Frontend-Build wieder frei.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 11:34:01 +02:00
27 changed files with 1006 additions and 321 deletions
+50 -12
View File
@@ -2,7 +2,7 @@
Digitales Yacht-Logbuch als Progressive Web App (PWA) — **kostenlos**, **werbefrei**, 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) **Live:** [kapteins-daagbok.eu](https://kapteins-daagbok.eu) · **Demo:** [kapteins-daagbok.eu/demo](https://kapteins-daagbok.eu/demo)
## Überblick ## Überblick
@@ -15,19 +15,29 @@ Alle sensiblen Inhalte werden **clientseitig verschlüsselt** (Web Crypto API).
- **Passkey-Authentifizierung** (WebAuthn) mit optionaler Recovery-Phrase und lokalem PIN-Fallback - **Passkey-Authentifizierung** (WebAuthn) mit optionaler Recovery-Phrase und lokalem PIN-Fallback
- **Mehrere Logbücher** pro Benutzerkonto — eigene Logbücher und per Einladung geteilte Logbücher (Crew-Zugang) klar getrennt - **Mehrere Logbücher** pro Benutzerkonto — eigene Logbücher und per Einladung geteilte Logbücher (Crew-Zugang) klar getrennt
- **Reisetage** mit Hafen, Wetter, Tankständen, Ereignissen und Tagesnummer - **Reisetage** mit Hafen, Wetter, Tankständen, Ereignissen und Tagesnummer
- **Kompass-Dial** für MgK- und RwK-Kurse — Ring-Eingabe, Gradfeld, Schrittweite 1°/5°/10° (maritime Orientierung: 0° = Nord)
- **GPS-Tracks** (GPX/KML/GeoJSON-Upload, Karte, Statistiken) - **GPS-Tracks** (GPX/KML/GeoJSON-Upload, Karte, Statistiken)
- **Foto-Anhänge** pro Reisetag - **Foto-Anhänge** pro Reisetag
- **Passkey-Signaturen** für Skipper und Crew (hybride elektronische Signatur) - **Passkey-Signaturen** für Skipper und Crew (hybride elektronische Signatur)
- **Schiffsdaten** und **Crew-Profile** (Skipper + Mitglieder) - **Schiffsdaten** und **Crew-Profile** (Skipper + Mitglieder)
- **Statistik-Dashboard** — Strecken, Verbrauch, Segel/Motor, Hafenkette (pro Logbuch oder accountweit) - **Statistik-Dashboard** — Strecken, Verbrauch, Segel/Motor, Hafenkette (pro Logbuch oder accountweit)
- **Benutzerprofil** — kontoweite Einstellungen: Darstellung (Theme, Hell/Dunkel), OpenWeatherMap-API-Key, Web Push, PWA-Installation, Onboarding-Tour, Passkey-Verwaltung, Account-Statistik
- **Kollaboration** — Crew per Einladungslink einladen (Schreib- oder Lesezugriff) - **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) - **Push-Benachrichtigungen** (optional) — Logbuch-Eigner per Web Push informieren, wenn Crew Änderungen synchronisiert (ohne Klartext-Inhalte; Opt-in im Benutzerprofil)
- **Read-only-Freigabe** — öffentlicher Lese-Link für Dritte - **Read-only-Freigabe** — öffentlicher Lese-Link für Dritte
- **Export** — PDF pro Reisetag, CSV-Download/-Teilen - **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 - **Backup & Wiederherstellung** — vollständiges verschlüsseltes Logbuch-Backup (Einträge, Fotos, GPS, Crew, Schiff) als `.daagbok.json`; Restore auf gleichem oder neuem Account
- **Feedback** — Bug-, Feature- und allgemeine Rückmeldungen aus der App (serverseitig via [Ntfy](https://ntfy.sh) oder self-hosted)
- **PWA** — installierbar auf iOS/Android, Offline-Modus, Update-Hinweise - **PWA** — installierbar auf iOS/Android, Offline-Modus, Update-Hinweise
- **Mehrsprachig** — Deutsch und Englisch - **Mehrsprachig** — Deutsch und Englisch
- **Demo-Logbuch & Onboarding-Tour** für neue Nutzer - **Demo-Logbuch & Onboarding-Tour** für neue Nutzer (auch unter `/demo` ohne Anmeldung)
### Benutzerprofil vs. Logbuch-Einstellungen
| Bereich | Inhalt |
|---------|--------|
| **Benutzerprofil** | Theme, Farbschema, Wetter-API-Key, Push, PWA, Tour, Passkeys, Account löschen |
| **Logbuch-Einstellungen** | Crew-Einladungen, öffentliche Freigabe, Backup & Wiederherstellung (nur Eigner) |
## Architektur ## Architektur
@@ -48,6 +58,7 @@ Alle sensiblen Inhalte werden **clientseitig verschlüsselt** (Web Crypto API).
| Auth | WebAuthn (Passkeys) + signiertes HttpOnly-Session-Cookie (`daagbok_session`) | | Auth | WebAuthn (Passkeys) + signiertes HttpOnly-Session-Cookie (`daagbok_session`) |
| Krypto | Web Crypto API (AES-GCM), BIP39 Recovery | | Krypto | Web Crypto API (AES-GCM), BIP39 Recovery |
| Push (optional) | Web Push (VAPID), Custom Service Worker (`injectManifest`) | | Push (optional) | Web Push (VAPID), Custom Service Worker (`injectManifest`) |
| Feedback (optional) | Ntfy (HTTP Publish) |
### Rollen & Zugriff ### Rollen & Zugriff
@@ -73,7 +84,7 @@ Skipper- und Crew-Profile im Logbuch sind **Inhaltsdaten** (verschlüsselt), nic
## Backup & Wiederherstellung ## Backup & Wiederherstellung
Nur der **Logbuch-Eigner** kann unter **Einstellungen → Backup & Wiederherstellung** ein vollständiges Backup erstellen: Nur der **Logbuch-Eigner** kann unter **Logbuch-Einstellungen → Backup & Wiederherstellung** ein vollständiges Backup erstellen:
1. Backup-Passphrase wählen (min. 8 Zeichen, getrennt von der Datei aufbewahren) 1. Backup-Passphrase wählen (min. 8 Zeichen, getrennt von der Datei aufbewahren)
2. Download als `.daagbok.json` — enthält alle verschlüsselten Payloads inkl. **Fotos** und GPS-Tracks 2. Download als `.daagbok.json` — enthält alle verschlüsselten Payloads inkl. **Fotos** und GPS-Tracks
@@ -83,7 +94,7 @@ Vor dem Löschen eines Logbuchs weist die App auf diese Funktion hin. Crew-Einla
## Push-Benachrichtigungen (optional) ## 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). Logbuch-**Eigner** können im **Benutzerprofil** 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 | | Aspekt | Verhalten |
|--------|-----------| |--------|-----------|
@@ -102,20 +113,32 @@ Schlüssel erzeugen: `npx web-push generate-vapid-keys` (im `server/`-Verzeichni
Ausführlicher Implementierungs- und Testplan: [docs/push-notifications-plan.md](docs/push-notifications-plan.md). Ausführlicher Implementierungs- und Testplan: [docs/push-notifications-plan.md](docs/push-notifications-plan.md).
## Feedback (optional)
Eingeloggte Nutzer können über das Feedback-Formular in der App Rückmeldungen senden. Der Server leitet sie an einen **Ntfy**-Topic weiter (kein Klartext-Logbuch auf dem Server).
| Variable | Bedeutung |
|----------|-----------|
| `NTFY_SERVER` | Basis-URL (Standard: `https://ntfy.sh`) |
| `NTFY_TOPIC` | Topic-Name (ohne URL) |
| `NTFY_TOKEN` | Optional: Access-Token für geschützte Topics |
Ohne `NTFY_TOPIC` antwortet die API mit „nicht konfiguriert“. Rate-Limiting und einfacher Spam-Schutz sind serverseitig aktiv.
## Projektstruktur ## Projektstruktur
``` ```
kapteins-daagbok/ kapteins-daagbok/
├── client/ # React-PWA (Frontend) ├── client/ # React-PWA (Frontend)
│ ├── src/ │ ├── src/
│ │ ├── components/ # UI-Komponenten │ │ ├── components/ # UI (u. a. CourseDialInput, UserProfilePage, FeedbackModal)
│ │ ├── services/ # Auth, Sync, Krypto, Backup, Push, Analytics, … │ │ ├── services/ # Auth, Sync, Krypto, Backup, Push, Analytics, …
│ │ ├── sw.ts # Service Worker (Precache + Web Push) │ │ ├── sw.ts # Service Worker (Precache + Web Push)
│ │ └── i18n/ # DE/EN-Übersetzungen │ │ └── i18n/ # DE/EN-Übersetzungen
│ └── Dockerfile # Nginx-Produktions-Image │ └── Dockerfile # Nginx-Produktions-Image
├── server/ # Express-API + Prisma ├── server/ # Express-API + Prisma
│ ├── src/routes/ # auth, logbooks, sync, collaboration, sign, push │ ├── src/routes/ # auth, logbooks, sync, collaboration, sign, push, feedback, weather
│ ├── src/services/ # z. B. pushNotify (Web Push) │ ├── src/services/ # z. B. pushNotify, ntfyNotify
│ └── prisma/ # Datenbankschema │ └── prisma/ # Datenbankschema
├── docs/ # Projektdokumentation ├── docs/ # Projektdokumentation
├── scripts/ # Dev- und Deploy-Skripte ├── scripts/ # Dev- und Deploy-Skripte
@@ -128,8 +151,9 @@ kapteins-daagbok/
- **Node.js** 20+ - **Node.js** 20+
- **npm** - **npm**
- **Docker** (für PostgreSQL in der Entwicklung oder den vollständigen Stack) - **Docker** (für PostgreSQL in der Entwicklung oder den vollständigen Stack)
- Optional: eigener OpenWeatherMap-API-Key in den Einstellungen (sonst serverseitiger Key aus `.env`) - Optional: eigener OpenWeatherMap-API-Key im **Benutzerprofil** (sonst serverseitiger Key aus `.env`)
- Optional: VAPID-Schlüssel für Web Push (siehe Abschnitt Push-Benachrichtigungen) - Optional: VAPID-Schlüssel für Web Push (siehe Abschnitt Push-Benachrichtigungen)
- Optional: Ntfy-Topic für Feedback (siehe Abschnitt Feedback)
## Lokale Entwicklung ## Lokale Entwicklung
@@ -166,6 +190,10 @@ SESSION_SECRET= # openssl rand -base64 48 (in Prod Pflicht)
VAPID_PUBLIC_KEY= VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY= VAPID_PRIVATE_KEY=
VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu
# Optional — Feedback via Ntfy
NTFY_SERVER=https://ntfy.sh
NTFY_TOPIC=
NTFY_TOKEN=
``` ```
`./scripts/start-dev.sh` prüft `ORIGIN` und `SESSION_SECRET` beim Start und gibt Hinweise aus. `./scripts/start-dev.sh` prüft `ORIGIN` und `SESSION_SECRET` beim Start und gibt Hinweise aus.
@@ -189,6 +217,15 @@ cd server && npx prisma db push && cd ..
| Frontend (Vite) | http://localhost:5173 | | Frontend (Vite) | http://localhost:5173 |
| Backend API | http://localhost:5000 | | Backend API | http://localhost:5000 |
| Health Check | http://localhost:5000/api/health | | Health Check | http://localhost:5000/api/health |
| Public Demo | http://localhost:5173/demo |
### 5. Tests (Frontend)
```bash
cd client && npm test
```
Vitest-Unit-Tests für Utils, i18n und Services (z. B. Kurswinkel, Benutzereinstellungen).
## Docker (produktionsnah) ## Docker (produktionsnah)
@@ -198,9 +235,9 @@ Gesamten Stack lokal bauen und starten:
./scripts/start-dev-docker.sh ./scripts/start-dev-docker.sh
``` ```
Frontend: http://localhost · API: http://localhost/api/health Frontend: http://localhost · API: http://localhost/api/health · Demo: http://localhost/demo
Umgebungsvariablen in `.env` setzen — mindestens `RP_ID`, `ORIGIN` (z. B. `http://localhost`) und `SESSION_SECRET`. Für Push die VAPID-Variablen an den Backend-Container durchreichen (`docker-compose.yml``backend.environment`). Umgebungsvariablen in `.env` setzen — mindestens `RP_ID`, `ORIGIN` (z. B. `http://localhost`) und `SESSION_SECRET`. Für Push die VAPID-Variablen an den Backend-Container durchreichen (`docker-compose.yml``backend.environment`). Für Feedback `NTFY_*` setzen.
## Deployment ## Deployment
@@ -212,7 +249,7 @@ 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. Standard-Ziel: `root@10.0.0.25:/opt/kapteins-daagbok` — per `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_DIR` überschreibbar.
Auf dem Server müssen `server/.env` (oder gleichwertige Umgebung) u. a. `DATABASE_URL`, `RP_ID`, `ORIGIN`, `SESSION_SECRET` (≥ 32 Zeichen) und bei Push `VAPID_*` enthalten. Nach Schema-Änderungen: `npx prisma db push` im Backend-Container. Auf dem Server müssen `server/.env` (oder gleichwertige Umgebung) u. a. `DATABASE_URL`, `RP_ID`, `ORIGIN`, `SESSION_SECRET` (≥ 32 Zeichen) und bei Push `VAPID_*` enthalten. Optional `NTFY_*` für Feedback. Nach Schema-Änderungen: `npx prisma db push` im Backend-Container.
## Dokumentation ## Dokumentation
@@ -220,6 +257,7 @@ Auf dem Server müssen `server/.env` (oder gleichwertige Umgebung) u. a. `DATABA
|----------|--------| |----------|--------|
| [docs/plausible-events.md](docs/plausible-events.md) | Custom Events für Plausible Analytics | | [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/push-notifications-plan.md](docs/push-notifications-plan.md) | Web Push: Architektur, API, Testplan |
| [docs/plan-compass-course-dial.md](docs/plan-compass-course-dial.md) | Kompass-Dial: UX- und Implementierungsplan |
| [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` | | [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) | | [.planning/PROJECT.md](.planning/PROJECT.md) | Produktvision und Anforderungen (GSD) |
+1 -1
View File
@@ -1 +1 @@
0.1.0.47 0.1.0.57
+3 -2
View File
@@ -17,7 +17,8 @@
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Daagbok" /> <meta name="apple-mobile-web-app-title" content="Daagbok" />
<meta name="theme-color" content="#1e293b" /> <meta name="theme-color" content="#0b0c10" />
<script src="/appearance-bootstrap.js"></script>
<link rel="apple-touch-icon" href="/logo.png" /> <link rel="apple-touch-icon" href="/logo.png" />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:site_name" content="Kapteins Daagbok" /> <meta property="og:site_name" content="Kapteins Daagbok" />
@@ -36,7 +37,7 @@
<script defer data-domain="kapteins-daagbok.eu" src="https://plausible.elpatron.me/js/script.tagged-events.js"></script> <script defer data-domain="kapteins-daagbok.eu" src="https://plausible.elpatron.me/js/script.tagged-events.js"></script>
<title>Kapteins Daagbok Kostenloses digitales Yacht-Logbuch (werbefrei)</title> <title>Kapteins Daagbok Kostenloses digitales Yacht-Logbuch (werbefrei)</title>
</head> </head>
<body style="margin:0;background:#0b0c10;color:#e2e8f0"> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
+44
View File
@@ -0,0 +1,44 @@
/**
* Applies saved appearance classes before CSS/JS bundle loads (prevents wrong flash on PWA).
* Logic mirrors client/src/services/appearance.ts + userPreferences.ts.
*/
(function () {
try {
var uid = localStorage.getItem('active_userid')
var theme = 'auto'
var scheme = 'auto'
if (uid) {
theme =
localStorage.getItem('user_pref_theme_' + uid) ||
localStorage.getItem('active_theme') ||
'auto'
scheme =
localStorage.getItem('user_pref_color_scheme_' + uid) ||
localStorage.getItem('active_color_scheme') ||
'auto'
} else {
theme = localStorage.getItem('active_theme') || 'auto'
scheme = localStorage.getItem('active_color_scheme') || 'auto'
}
var resolvedTheme = theme
if (resolvedTheme !== 'ocean' && resolvedTheme !== 'material' && resolvedTheme !== 'cupertino') {
var ua = navigator.userAgent || navigator.vendor || ''
if (/iPad|iPhone|iPod|Macintosh/.test(ua)) resolvedTheme = 'cupertino'
else if (/Android|Linux/.test(ua)) resolvedTheme = 'material'
else resolvedTheme = 'ocean'
}
var resolvedScheme = scheme
if (resolvedScheme !== 'light' && resolvedScheme !== 'dark') {
resolvedScheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
var root = document.documentElement
root.classList.add('theme-' + resolvedTheme, 'scheme-' + resolvedScheme)
root.style.colorScheme = resolvedScheme
} catch (_) {
/* ignore storage / matchMedia errors */
}
})()
+93 -2
View File
@@ -1097,6 +1097,7 @@ html.scheme-dark .themed-select-option.is-selected {
gap: 12px; gap: 12px;
} }
.profile-field-label,
.profile-pin-form .input-group label { .profile-pin-form .input-group label {
display: block; display: block;
text-align: left; text-align: left;
@@ -2171,6 +2172,12 @@ html.scheme-dark .themed-select-option.is-selected {
100% { background-position: 0 0; } 100% { background-position: 0 0; }
} }
.conn-status.syncing {
background: rgba(59, 130, 246, 0.1);
color: #60a5fa;
border: 1px solid rgba(59, 130, 246, 0.25);
}
.conn-status.warning { .conn-status.warning {
background: rgba(251, 191, 36, 0.1); background: rgba(251, 191, 36, 0.1);
color: #fbbf24; color: #fbbf24;
@@ -2433,6 +2440,32 @@ html.scheme-dark .themed-select-option.is-selected {
.track-map-container { .track-map-container {
height: min(360px, 45svh); height: min(360px, 45svh);
} }
.sails-picker-pills {
gap: 4px;
}
.sails-picker-container.is-collapsible .sails-picker-toggle {
display: inline-flex;
}
.sail-pill {
padding: 3px 8px;
font-size: 11px;
border-radius: 12px;
line-height: 1.2;
}
}
@media (max-width: 480px) {
.sails-picker-container.is-collapsible.is-collapsed .sails-picker-pills {
max-height: 3.25rem;
}
.sail-pill {
padding: 2px 7px;
font-size: 10px;
}
} }
/* ========================================== */ /* ========================================== */
@@ -2696,13 +2729,48 @@ html.theme-cupertino .events-scroll-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
margin-top: 8px; grid-column: 1 / -1;
margin-top: -4px;
} }
.sails-picker-pills { .sails-picker-pills {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 6px; gap: 6px;
position: relative;
}
.sails-picker-container.is-collapsible.is-collapsed .sails-picker-pills {
max-height: 3.75rem;
overflow: hidden;
}
.sails-picker-container.is-collapsible.is-collapsed .sails-picker-pills::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1.25rem;
background: linear-gradient(to bottom, transparent, var(--app-surface, #0f172a));
pointer-events: none;
}
.sails-picker-toggle {
display: none;
align-items: center;
gap: 4px;
margin: 4px auto 0;
padding: 2px 8px;
background: none;
border: none;
color: var(--app-text-muted, #94a3b8);
font-size: 12px;
cursor: pointer;
}
.sails-picker-toggle:hover {
color: var(--app-accent, #fbbf24);
} }
.sail-pill { .sail-pill {
@@ -2715,6 +2783,7 @@ html.theme-cupertino .events-scroll-container {
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
transition: all 0.2s ease; transition: all 0.2s ease;
white-space: nowrap;
} }
.sail-pill:hover { .sail-pill:hover {
@@ -2742,7 +2811,9 @@ html.theme-cupertino .events-scroll-container {
background: rgba(56, 189, 248, 0.15); background: rgba(56, 189, 248, 0.15);
border-color: #38bdf8; border-color: #38bdf8;
color: #38bdf8; color: #38bdf8;
}.grid-span-2 { }
.grid-span-2 {
grid-column: span 2; grid-column: span 2;
} }
@@ -4062,6 +4133,26 @@ html.theme-cupertino .events-scroll-container {
border: 1px solid rgba(148, 163, 184, 0.25); border: 1px solid rgba(148, 163, 184, 0.25);
} }
.backup-panel {
text-align: left;
}
.backup-export-form,
.backup-import-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.backup-panel .input-group label {
display: block;
font-size: 13.5px;
color: var(--app-text-muted);
margin-bottom: 6px;
font-weight: 500;
text-align: left;
}
.backup-panel .backup-section { .backup-panel .backup-section {
margin-bottom: 28px; margin-bottom: 28px;
padding-bottom: 24px; padding-bottom: 24px;
+7 -6
View File
@@ -16,10 +16,10 @@ import AppTourOverlay from './components/AppTourOverlay.tsx'
import { AppTourProvider, useAppTour, type AppTab } from './context/AppTourContext.tsx' import { AppTourProvider, useAppTour, type AppTab } from './context/AppTourContext.tsx'
import { UnsavedChangesProvider, useUnsavedChangesContext } from './context/UnsavedChangesContext.tsx' import { UnsavedChangesProvider, useUnsavedChangesContext } from './context/UnsavedChangesContext.tsx'
import { import {
getActiveMasterKey,
logoutUser, logoutUser,
checkServerSession, checkServerSession,
hasUnlockedLocalSession hasUnlockedLocalSession,
persistSessionUserId
} from './services/auth.js' } from './services/auth.js'
import AppErrorBoundary from './components/AppErrorBoundary.tsx' import AppErrorBoundary from './components/AppErrorBoundary.tsx'
import { PlausibleEvents, trackPlausibleEvent } from './services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from './services/analytics.js'
@@ -226,6 +226,7 @@ function App() {
/** After PWA/bfcache resume, React state may still say "logged in" while the master key is gone. */ /** After PWA/bfcache resume, React state may still say "logged in" while the master key is gone. */
const enforceUnlockedSession = useCallback(() => { const enforceUnlockedSession = useCallback(() => {
if (isViewerMode || isDemoMode || isAcceptingInvite) return if (isViewerMode || isDemoMode || isAcceptingInvite) return
// Require full local session (incl. userId) so API calls are not left headless.
if (isAuthenticated && !hasUnlockedLocalSession()) { if (isAuthenticated && !hasUnlockedLocalSession()) {
clearAuthenticatedAppState() clearAuthenticatedAppState()
} }
@@ -268,10 +269,11 @@ function App() {
const session = await checkServerSession() const session = await checkServerSession()
if (cancelled) return if (cancelled) return
if (session.authenticated && session.userId) { if (session.authenticated) {
localStorage.setItem('active_userid', session.userId) persistSessionUserId(session.userId)
} }
// Cookie alone is insufficient — need in-memory master key, username, and userId for API.
if (session.authenticated && hasUnlockedLocalSession()) { if (session.authenticated && hasUnlockedLocalSession()) {
setIsAuthenticated(true) setIsAuthenticated(true)
const savedLogbookId = localStorage.getItem('active_logbook_id') const savedLogbookId = localStorage.getItem('active_logbook_id')
@@ -280,9 +282,8 @@ function App() {
setActiveLogbookId(savedLogbookId) setActiveLogbookId(savedLogbookId)
setActiveLogbookTitle(savedLogbookTitle) setActiveLogbookTitle(savedLogbookTitle)
} }
} else if (session.authenticated) {
clearAuthenticatedAppState()
} }
// authenticated + crypto but no userId: stay on login (enforceUnlockedSession guards active UI)
} catch (err) { } catch (err) {
if (!cancelled) { if (!cancelled) {
console.warn('Session restore failed:', err) console.warn('Session restore failed:', err)
+14 -3
View File
@@ -13,6 +13,7 @@ import {
} from '../services/auth.js' } from '../services/auth.js'
import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react' import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react'
import RegistrationDisclaimer from './RegistrationDisclaimer.tsx' import RegistrationDisclaimer from './RegistrationDisclaimer.tsx'
import DisclaimerModal from './DisclaimerModal.tsx'
import BetaBadge from './BetaBadge.tsx' import BetaBadge from './BetaBadge.tsx'
interface AuthOnboardingProps { interface AuthOnboardingProps {
@@ -50,6 +51,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
const [isNewRegistration, setIsNewRegistration] = useState(false) const [isNewRegistration, setIsNewRegistration] = useState(false)
const [showDisclaimer, setShowDisclaimer] = useState(false) const [showDisclaimer, setShowDisclaimer] = useState(false)
const [showHelp, setShowHelp] = useState(false)
const finishAuth = () => { const finishAuth = () => {
if (isNewRegistration) { if (isNewRegistration) {
@@ -410,6 +412,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
// Render 3: Standard Login / Registration options form // Render 3: Standard Login / Registration options form
return ( return (
<>
<div className="auth-card glass"> <div className="auth-card glass">
<div className="auth-brand"> <div className="auth-brand">
<img src="/logo.png" alt="Kapteins Daagbok" className="auth-logo-img" /> <img src="/logo.png" alt="Kapteins Daagbok" className="auth-logo-img" />
@@ -570,15 +573,23 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
</div> </div>
<div className="auth-footer"> <div className="auth-footer">
<button className="btn-icon-text" onClick={toggleLanguage}> <button type="button" className="btn-icon-text" onClick={toggleLanguage}>
<Languages size={18} /> <Languages size={18} />
{i18n.language.startsWith('de') ? 'English' : 'Deutsch'} {i18n.language.startsWith('de') ? 'English' : 'Deutsch'}
</button> </button>
<a href="#help" className="btn-icon-text link-sec"> <button
type="button"
className="btn-icon-text link-sec"
onClick={() => setShowHelp(true)}
title={t('disclaimer.button_title')}
aria-label={t('disclaimer.button_title')}
>
<HelpCircle size={18} /> <HelpCircle size={18} />
{t('auth.help')} {t('auth.help')}
</a> </button>
</div> </div>
</div> </div>
<DisclaimerModal open={showHelp} onClose={() => setShowHelp(false)} />
</>
) )
} }
+72 -21
View File
@@ -6,7 +6,7 @@ import { getLogbookKey } from '../services/logbookKeys.js'
import { encryptJson, decryptJson } from '../services/crypto.js' import { encryptJson, decryptJson } from '../services/crypto.js'
import { syncLogbook } from '../services/sync.js' import { syncLogbook } from '../services/sync.js'
import { downloadLogbookPagePdf } from '../services/pdfExport.js' import { downloadLogbookPagePdf } from '../services/pdfExport.js'
import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X } from 'lucide-react' import { FileText, Save, ChevronLeft, Check, Compass, Plus, Trash2, MapPin, CloudSun, Clock, Download, Upload, Pencil, X, ChevronDown, ChevronUp } from 'lucide-react'
import PhotoCapture from './PhotoCapture.tsx' import PhotoCapture from './PhotoCapture.tsx'
import SignatureSection from './SignatureSection.tsx' import SignatureSection from './SignatureSection.tsx'
import TrackMap from './TrackMap.tsx' import TrackMap from './TrackMap.tsx'
@@ -176,6 +176,7 @@ export default function LogEntryEditor({
const [evCurrent, setEvCurrent] = useState('') const [evCurrent, setEvCurrent] = useState('')
const [evHeel, setEvHeel] = useState('') const [evHeel, setEvHeel] = useState('')
const [evSailsOrMotor, setEvSailsOrMotor] = useState('') const [evSailsOrMotor, setEvSailsOrMotor] = useState('')
const [sailsPickerExpanded, setSailsPickerExpanded] = useState(false)
const [evLogReading, setEvLogReading] = useState('') const [evLogReading, setEvLogReading] = useState('')
const [evDistance, setEvDistance] = useState('') const [evDistance, setEvDistance] = useState('')
const [evGpsLat, setEvGpsLat] = useState('') const [evGpsLat, setEvGpsLat] = useState('')
@@ -842,6 +843,9 @@ export default function LogEntryEditor({
? ['Großsegel', 'Genua', 'Fock', 'Spinnaker', 'Gennaker'] ? ['Großsegel', 'Genua', 'Fock', 'Spinnaker', 'Gennaker']
: ['Mainsail', 'Genoa', 'Jib', 'Spinnaker', 'Gennaker'] : ['Mainsail', 'Genoa', 'Jib', 'Spinnaker', 'Gennaker']
const eventSailOptions = yachtSails.length > 0 ? yachtSails : defaultSails
const showSailsPickerToggle = eventSailOptions.length + 1 > 6
const toggleSailOrMotor = (item: string) => { const toggleSailOrMotor = (item: string) => {
let currentItems = evSailsOrMotor let currentItems = evSailsOrMotor
.split(/\s*(?:\+|\bplus\b|,)\s*/i) .split(/\s*(?:\+|\bplus\b|,)\s*/i)
@@ -865,6 +869,15 @@ export default function LogEntryEditor({
return currentItems.includes(item.toLowerCase()) return currentItems.includes(item.toLowerCase())
} }
const motorPropulsionLabel = t('logs.motor_propulsion')
const sortedEventSailOptions = [...eventSailOptions].sort((a, b) => {
const aActive = isItemActive(a)
const bActive = isItemActive(b)
if (aActive === bActive) return 0
return aActive ? -1 : 1
})
const isMotorActive = isItemActive(motorPropulsionLabel)
const clearEventForm = () => { const clearEventForm = () => {
setEvTime(currentLocalTimeHHMM()) setEvTime(currentLocalTimeHHMM())
setEvMgk('') setEvMgk('')
@@ -884,6 +897,7 @@ export default function LogEntryEditor({
setEvRemarks('') setEvRemarks('')
setEvLocationName('') setEvLocationName('')
setEditingEventIndex(null) setEditingEventIndex(null)
setSailsPickerExpanded(false)
} }
const fillEventForm = (ev: LogEvent) => { const fillEventForm = (ev: LogEvent) => {
@@ -1559,25 +1573,6 @@ export default function LogEntryEditor({
onChange={(e) => setEvSailsOrMotor(e.target.value)} onChange={(e) => setEvSailsOrMotor(e.target.value)}
disabled={saving} disabled={saving}
/> />
<div className="sails-picker-container">
<div className="sails-picker-pills">
{(yachtSails.length > 0 ? yachtSails : defaultSails).map((sail) => (
<span
key={sail}
className={`sail-pill ${isItemActive(sail) ? 'active' : ''}`}
onClick={() => toggleSailOrMotor(sail)}
>
{sail}
</span>
))}
<span
className={`sail-pill motor-pill ${isItemActive(t('logs.motor_propulsion')) ? 'active' : ''}`}
onClick={() => toggleSailOrMotor(t('logs.motor_propulsion'))}
>
{t('logs.motor_propulsion')}
</span>
</div>
</div>
</div> </div>
<div className="input-group"> <div className="input-group">
@@ -1592,7 +1587,63 @@ export default function LogEntryEditor({
/> />
</div> </div>
<div className="input-group" style={{ gridColumn: 'span 2' }}> <div
className={[
'sails-picker-container grid-span-2',
showSailsPickerToggle ? 'is-collapsible' : '',
showSailsPickerToggle && !sailsPickerExpanded ? 'is-collapsed' : '',
].filter(Boolean).join(' ')}
>
<div className="sails-picker-pills">
{isMotorActive && (
<span
className={`sail-pill motor-pill active`}
onClick={() => toggleSailOrMotor(motorPropulsionLabel)}
>
{motorPropulsionLabel}
</span>
)}
{sortedEventSailOptions.map((sail) => (
<span
key={sail}
className={`sail-pill ${isItemActive(sail) ? 'active' : ''}`}
onClick={() => toggleSailOrMotor(sail)}
>
{sail}
</span>
))}
{!isMotorActive && (
<span
className="sail-pill motor-pill"
onClick={() => toggleSailOrMotor(motorPropulsionLabel)}
>
{motorPropulsionLabel}
</span>
)}
</div>
{showSailsPickerToggle && (
<button
type="button"
className="sails-picker-toggle"
onClick={() => setSailsPickerExpanded((prev) => !prev)}
aria-expanded={sailsPickerExpanded}
>
{sailsPickerExpanded ? (
<>
<ChevronUp size={14} aria-hidden="true" />
{t('logs.sails_picker_show_less')}
</>
) : (
<>
<ChevronDown size={14} aria-hidden="true" />
{t('logs.sails_picker_show_more')}
</>
)}
</button>
)}
</div>
<div className="input-group grid-span-2">
<label>{t('logs.event_remarks')}</label> <label>{t('logs.event_remarks')}</label>
<input <input
type="text" type="text"
+20 -6
View File
@@ -1,7 +1,6 @@
import React, { useState, useEffect, useRef } from 'react' import React, { useState, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useLiveQuery } from 'dexie-react-hooks' import { useSyncIndicator } from '../hooks/useSyncIndicator.js'
import { db } from '../services/db.js'
import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type DecryptedLogbook } from '../services/logbook.js' import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type DecryptedLogbook } from '../services/logbook.js'
import LogbookRoleBadge from './LogbookRoleBadge.tsx' import LogbookRoleBadge from './LogbookRoleBadge.tsx'
import BetaBadge from './BetaBadge.tsx' import BetaBadge from './BetaBadge.tsx'
@@ -32,8 +31,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
const [online, setOnline] = useState(navigator.onLine) const [online, setOnline] = useState(navigator.onLine)
const [username] = useState(localStorage.getItem('active_username') || 'Skipper') const [username] = useState(localStorage.getItem('active_username') || 'Skipper')
// Reactive sync queue count const { pendingCount, showSpinner, showPendingWarning, connStatusClassName } = useSyncIndicator()
const pendingCount = useLiveQuery(() => db.syncQueue.count()) || 0
// Listen to connectivity changes // Listen to connectivity changes
useEffect(() => { useEffect(() => {
@@ -272,11 +270,27 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
<div className="header-actions"> <div className="header-actions">
{/* Connection Indicator */} {/* Connection Indicator */}
<div className={`conn-status ${online ? (pendingCount > 0 ? 'unsynced' : 'online') : 'offline'}`} title={online ? (pendingCount > 0 ? 'Pending Sync' : 'Synced') : 'Offline'}> <div
className={connStatusClassName(online)}
title={
online
? showSpinner
? 'Syncing'
: pendingCount > 0
? 'Pending Sync'
: 'Synced'
: 'Offline'
}
>
{online ? ( {online ? (
pendingCount > 0 ? ( showSpinner ? (
<> <>
<RefreshCw size={18} className="spin" /> <RefreshCw size={18} className="spin" />
<span>{t('sync.status_syncing')}</span>
</>
) : showPendingWarning ? (
<>
<RefreshCw size={18} />
<span>{t('sync.status_unsynced')} ({pendingCount})</span> <span>{t('sync.status_unsynced')} ({pendingCount})</span>
</> </>
) : ( ) : (
@@ -56,7 +56,7 @@ export default function PushNotificationSettings() {
trackPlausibleEvent(PlausibleEvents.PUSH_DISABLED) trackPlausibleEvent(PlausibleEvents.PUSH_DISABLED)
} }
} catch (err: unknown) { } catch (err: unknown) {
const message = err instanceof Error ? err.message : t('settings.push_error') const message = err instanceof Error ? err.message : t('profile.push_error')
showAlert(message) showAlert(message)
void loadPrefs() void loadPrefs()
} finally { } finally {
@@ -69,10 +69,10 @@ export default function PushNotificationSettings() {
<div className="member-editor-card glass mt-4"> <div className="member-editor-card glass mt-4">
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
<BellOff size={20} style={{ color: '#94a3b8' }} /> <BellOff size={20} style={{ color: '#94a3b8' }} />
<h3 style={{ margin: 0, color: '#94a3b8', fontSize: '16px' }}>{t('settings.push_title')}</h3> <h3 style={{ margin: 0, color: '#94a3b8', fontSize: '16px' }}>{t('profile.push_title')}</h3>
</div> </div>
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: 0 }}> <p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: 0 }}>
{t('settings.push_unsupported')} {t('profile.push_unsupported')}
</p> </p>
</div> </div>
) )
@@ -83,23 +83,23 @@ export default function PushNotificationSettings() {
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
<Bell size={20} style={{ color: 'var(--app-accent-light)' }} /> <Bell size={20} style={{ color: 'var(--app-accent-light)' }} />
<h3 style={{ margin: 0, color: 'var(--app-accent-light)', fontSize: '16px' }}> <h3 style={{ margin: 0, color: 'var(--app-accent-light)', fontSize: '16px' }}>
{t('settings.push_title')} {t('profile.push_title')}
</h3> </h3>
</div> </div>
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: '0 0 16px 0' }}> <p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: '0 0 16px 0' }}>
{t('settings.push_desc')} {t('profile.push_desc')}
</p> </p>
{iosNeedsInstall && ( {iosNeedsInstall && (
<p className="text-muted" style={{ fontSize: '13px', margin: '0 0 12px 0' }}> <p className="text-muted" style={{ fontSize: '13px', margin: '0 0 12px 0' }}>
{t('settings.push_ios_install_hint')} {t('profile.push_ios_install_hint')}
</p> </p>
)} )}
{permission === 'denied' && ( {permission === 'denied' && (
<p style={{ fontSize: '13px', color: '#f87171', margin: '0 0 12px 0' }}> <p style={{ fontSize: '13px', color: '#f87171', margin: '0 0 12px 0' }}>
{t('settings.push_denied_hint')} {t('profile.push_denied_hint')}
</p> </p>
)} )}
@@ -122,12 +122,12 @@ export default function PushNotificationSettings() {
disabled={loading || toggling || iosNeedsInstall} disabled={loading || toggling || iosNeedsInstall}
style={{ width: '18px', height: '18px', cursor: 'inherit' }} style={{ width: '18px', height: '18px', cursor: 'inherit' }}
/> />
<span>{t('settings.push_enable')}</span> <span>{t('profile.push_enable')}</span>
</label> </label>
{enabled && permission === 'granted' && ( {enabled && permission === 'granted' && (
<p className="text-muted" style={{ fontSize: '12px', margin: '12px 0 0 0' }}> <p className="text-muted" style={{ fontSize: '12px', margin: '12px 0 0 0' }}>
{t('settings.push_active')} {t('profile.push_active')}
</p> </p>
)} )}
</div> </div>
+22 -174
View File
@@ -1,14 +1,9 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Settings as SettingsIcon, Save, Check, Users, Trash2, Copy, Link as LinkIcon, Compass } from 'lucide-react' import { Settings as SettingsIcon, Check, Users, Trash2, Copy, Link as LinkIcon } from 'lucide-react'
import { ensureLogbookKey } from '../services/logbookKeys.js' import { ensureLogbookKey } from '../services/logbookKeys.js'
import LogbookBackupPanel from './LogbookBackupPanel.tsx' import LogbookBackupPanel from './LogbookBackupPanel.tsx'
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
import PushNotificationSettings from './PushNotificationSettings.tsx'
import { useDialog } from './ModalDialog.tsx' import { useDialog } from './ModalDialog.tsx'
import { notifyAppearanceChanged } from '../services/appearance.js'
import ThemedSelect from './ThemedSelect.tsx'
import { useAppTour } from '../context/AppTourContext.tsx'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js' import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import { apiFetch } from '../services/api.js' import { apiFetch } from '../services/api.js'
@@ -25,7 +20,6 @@ interface Collaborator {
createdAt: string createdAt: string
} }
// Convert ArrayBuffer to Hex String for URL fragment
const bufferToHex = (buffer: ArrayBuffer): string => { const bufferToHex = (buffer: ArrayBuffer): string => {
return Array.from(new Uint8Array(buffer)) return Array.from(new Uint8Array(buffer))
.map(b => b.toString(16).padStart(2, '0')) .map(b => b.toString(16).padStart(2, '0'))
@@ -35,14 +29,7 @@ const bufferToHex = (buffer: ArrayBuffer): string => {
export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsFormProps) { export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsFormProps) {
const { t } = useTranslation() const { t } = useTranslation()
const { showConfirm, showAlert } = useDialog() const { showConfirm, showAlert } = useDialog()
const { restartTour } = useAppTour()
const [apiKey, setApiKey] = useState(localStorage.getItem('owm_api_key') || '')
const [theme, setTheme] = useState(localStorage.getItem('active_theme') || 'auto')
const [colorScheme, setColorScheme] = useState(localStorage.getItem('active_color_scheme') || 'auto')
const [saving, setSaving] = useState(false)
const [success, setSuccess] = useState(false)
// Collaboration States
const [collaborators, setCollaborators] = useState<Collaborator[]>([]) const [collaborators, setCollaborators] = useState<Collaborator[]>([])
const [isOwner, setIsOwner] = useState(true) const [isOwner, setIsOwner] = useState(true)
const [inviteLink, setInviteLink] = useState('') const [inviteLink, setInviteLink] = useState('')
@@ -51,7 +38,6 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
const [collabError, setCollabError] = useState<string | null>(null) const [collabError, setCollabError] = useState<string | null>(null)
const [loadingCollabs, setLoadingCollabs] = useState(false) const [loadingCollabs, setLoadingCollabs] = useState(false)
// Public Share Link States
const [shareEnabled, setShareEnabled] = useState(false) const [shareEnabled, setShareEnabled] = useState(false)
const [shareLink, setShareLink] = useState('') const [shareLink, setShareLink] = useState('')
const [shareCopied, setShareCopied] = useState(false) const [shareCopied, setShareCopied] = useState(false)
@@ -120,9 +106,9 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
} else { } else {
throw new Error('Failed to toggle public share link.') throw new Error('Failed to toggle public share link.')
} }
} catch (err: any) { } catch (err: unknown) {
console.error('Toggle share link failed:', err) console.error('Toggle share link failed:', err)
showAlert(err.message || 'Failed to update public share link.') showAlert(err instanceof Error ? err.message : 'Failed to update public share link.')
} finally { } finally {
setLoadingShareLink(false) setLoadingShareLink(false)
} }
@@ -136,7 +122,6 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
} }
} }
const loadCollaborators = async () => { const loadCollaborators = async () => {
setLoadingCollabs(true) setLoadingCollabs(true)
setCollabError(null) setCollabError(null)
@@ -173,10 +158,8 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
if (!localStorage.getItem('active_userid')) return if (!localStorage.getItem('active_userid')) return
try { try {
// 1. Ensure logbook has an E2E key (upgrades legacy logbooks if needed)
const logbookKey = await ensureLogbookKey(logbookId) const logbookKey = await ensureLogbookKey(logbookId)
// 2. Create invite token on server
const res = await apiFetch('/api/collaboration/invite', { const res = await apiFetch('/api/collaboration/invite', {
method: 'POST', method: 'POST',
body: JSON.stringify({ logbookId, role: 'WRITE' }) body: JSON.stringify({ logbookId, role: 'WRITE' })
@@ -187,16 +170,14 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
} }
const invite = await res.json() const invite = await res.json()
// 3. Format link containing token (URL params) and key (URL hash anchor)
const hexKey = bufferToHex(logbookKey) const hexKey = bufferToHex(logbookKey)
const link = `${window.location.origin}/invite?token=${invite.token}#key=${hexKey}` const link = `${window.location.origin}/invite?token=${invite.token}#key=${hexKey}`
setInviteLink(link) setInviteLink(link)
trackPlausibleEvent(PlausibleEvents.INVITE_GENERATED) trackPlausibleEvent(PlausibleEvents.INVITE_GENERATED)
} catch (err: any) { } catch (err: unknown) {
console.error('Failed to generate invite:', err) console.error('Failed to generate invite:', err)
showAlert(err.message || 'Failed to generate invite link.') showAlert(err instanceof Error ? err.message : 'Failed to generate invite link.')
} finally { } finally {
setGeneratingInvite(false) setGeneratingInvite(false)
} }
@@ -225,40 +206,26 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
} else { } else {
throw new Error('Failed to revoke collaborator access.') throw new Error('Failed to revoke collaborator access.')
} }
} catch (err: any) { } catch (err: unknown) {
console.error('Revocation failed:', err) console.error('Revocation failed:', err)
showAlert(err.message || 'Failed to revoke access.') showAlert(err instanceof Error ? err.message : 'Failed to revoke access.')
} }
} }
} }
const persistAppearance = (nextTheme: string, nextColorScheme: string) => { if (!logbookId) {
localStorage.setItem('active_theme', nextTheme) return (
localStorage.setItem('active_color_scheme', nextColorScheme) <div className="form-card">
notifyAppearanceChanged() <div className="form-header">
} <SettingsIcon size={24} className="form-icon" />
<div>
const handleThemeChange = (nextTheme: string) => { <h2>{t('settings.title')}</h2>
setTheme(nextTheme) <p className="form-subtitle">{t('settings.subtitle')}</p>
persistAppearance(nextTheme, colorScheme) </div>
} </div>
<p className="text-muted mt-4">{t('settings.select_logbook_hint')}</p>
const handleColorSchemeChange = (nextColorScheme: string) => { </div>
setColorScheme(nextColorScheme) )
persistAppearance(theme, nextColorScheme)
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
setSaving(true)
setSuccess(false)
localStorage.setItem('owm_api_key', apiKey.trim())
persistAppearance(theme, colorScheme)
setSaving(false)
setSuccess(true)
setTimeout(() => setSuccess(false), 3000)
} }
return ( return (
@@ -267,128 +234,12 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
<SettingsIcon size={24} className="form-icon" /> <SettingsIcon size={24} className="form-icon" />
<div> <div>
<h2>{t('settings.title')}</h2> <h2>{t('settings.title')}</h2>
<p className="form-subtitle" style={{ margin: '4px 0 0 0', fontSize: '13px', color: '#94a3b8' }}> <p className="form-subtitle">{t('settings.subtitle')}</p>
{t('settings.subtitle')}
</p>
</div> </div>
</div> </div>
<form onSubmit={handleSubmit} className="vessel-form mt-6">
<PwaInstallPrompt variant="inline" />
<PushNotificationSettings />
{/* Weather Integration card */}
<div className="member-editor-card glass">
<h3 style={{ marginTop: 0, marginBottom: '12px', color: '#fbbf24', fontSize: '16px' }}>
{t('settings.owm_title')}
</h3>
<p style={{ fontSize: '13.5px', color: '#94a3b8', lineHeight: '145%', margin: '0 0 16px 0' }}>
{t('settings.key_help')}
</p>
<div className="input-group">
<label htmlFor="owm-api-key" style={{ display: 'block', fontSize: '13.5px', color: '#94a3b8', marginBottom: '6px', fontWeight: 500 }}>
{t('settings.owm_key')}
</label>
<input
id="owm-api-key"
name="owm-api-key"
type="password"
className="input-text"
placeholder="e.g. 8b6a7f...d8"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
disabled={saving}
autoComplete="off"
/>
</div>
</div>
{/* Theme customization card */}
<div className="member-editor-card glass mt-4">
<h3 style={{ marginTop: 0, marginBottom: '12px', color: '#fbbf24', fontSize: '16px' }}>
{t('settings.theme_title')}
</h3>
<p style={{ fontSize: '13.5px', color: '#94a3b8', lineHeight: '145%', margin: '0 0 16px 0' }}>
{t('settings.theme_label')}
</p>
<div className="input-group">
<ThemedSelect
id="app-theme"
value={theme}
disabled={saving}
onChange={handleThemeChange}
options={[
{ value: 'auto', label: t('settings.theme_auto') },
{ value: 'ocean', label: t('settings.theme_ocean') },
{ value: 'material', label: t('settings.theme_material') },
{ value: 'cupertino', label: t('settings.theme_cupertino') }
]}
/>
</div>
</div>
<div className="member-editor-card glass mt-4">
<h3 style={{ marginTop: 0, marginBottom: '12px', color: 'var(--app-accent-light)', fontSize: '16px' }}>
{t('settings.color_scheme_title')}
</h3>
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: '0 0 16px 0' }}>
{t('settings.color_scheme_label')}
</p>
<div className="input-group">
<ThemedSelect
id="app-color-scheme"
value={colorScheme}
disabled={saving}
onChange={handleColorSchemeChange}
options={[
{ value: 'auto', label: t('settings.color_scheme_auto') },
{ value: 'light', label: t('settings.color_scheme_light') },
{ value: 'dark', label: t('settings.color_scheme_dark') }
]}
/>
</div>
</div>
<div className="member-editor-card glass mt-4">
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
<Compass size={20} style={{ color: 'var(--app-accent-light)' }} />
<h3 style={{ margin: 0, color: 'var(--app-accent-light)', fontSize: '16px' }}>
{t('settings.tour_title')}
</h3>
</div>
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: '0 0 16px 0' }}>
{t('settings.tour_desc')}
</p>
<button
type="button"
className="btn secondary"
onClick={() => restartTour()}
>
{t('settings.tour_restart')}
</button>
</div>
<div className="form-actions mt-4 mb-6">
{success && (
<div className="success-toast">
<Check size={16} />
<span>{t('settings.saved')}</span>
</div>
)}
<button type="submit" className="btn primary" disabled={saving}>
<Save size={18} />
{saving ? t('settings.saving') : t('settings.save')}
</button>
</div>
</form>
{/* Public Share Link Card (Only visible to Logbook Owner) */}
{logbookId && isOwner && ( {logbookId && isOwner && (
<div className="member-editor-card glass mt-6" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '24px' }}> <div className="member-editor-card glass mt-6">
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
<LinkIcon size={20} style={{ color: '#fbbf24' }} /> <LinkIcon size={20} style={{ color: '#fbbf24' }} />
<h3 style={{ margin: 0, color: '#fbbf24', fontSize: '16px' }}> <h3 style={{ margin: 0, color: '#fbbf24', fontSize: '16px' }}>
@@ -441,12 +292,10 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
</div> </div>
)} )}
{/* Backup & Restore (owner only) */}
{logbookId && isOwner && ( {logbookId && isOwner && (
<LogbookBackupPanel logbookId={logbookId} onRestored={onLogbookRestored} /> <LogbookBackupPanel logbookId={logbookId} onRestored={onLogbookRestored} />
)} )}
{/* Crew Collaboration Card (Only visible to Logbook Owner) */}
{logbookId && isOwner && ( {logbookId && isOwner && (
<div className="member-editor-card glass mt-6" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '24px' }}> <div className="member-editor-card glass mt-6" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '24px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px' }}>
@@ -494,7 +343,6 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
</div> </div>
)} )}
{/* Collaborator List */}
<h4 style={{ color: '#fbbf24', fontSize: '14px', marginBottom: '12px' }}> <h4 style={{ color: '#fbbf24', fontSize: '14px', marginBottom: '12px' }}>
{t('logs.collaborators_list')} {t('logs.collaborators_list')}
</h4> </h4>
+17 -3
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useLiveQuery } from 'dexie-react-hooks' import { useLiveQuery } from 'dexie-react-hooks'
import { useSyncIndicator } from '../hooks/useSyncIndicator.js'
import { import {
User, User,
ChevronLeft, ChevronLeft,
@@ -28,6 +29,7 @@ import {
CircleAlert CircleAlert
} from 'lucide-react' } from 'lucide-react'
import AccountDangerZone from './AccountDangerZone.tsx' import AccountDangerZone from './AccountDangerZone.tsx'
import UserProfilePreferences from './UserProfilePreferences.tsx'
import BetaBadge from './BetaBadge.tsx' import BetaBadge from './BetaBadge.tsx'
import { useDialog } from './ModalDialog.tsx' import { useDialog } from './ModalDialog.tsx'
import { import {
@@ -127,7 +129,12 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
const [pendingRecoveryPhrase, setPendingRecoveryPhrase] = useState<string | null>(null) const [pendingRecoveryPhrase, setPendingRecoveryPhrase] = useState<string | null>(null)
const [recoveryCopied, setRecoveryCopied] = useState(false) const [recoveryCopied, setRecoveryCopied] = useState(false)
const pendingSyncCount = useLiveQuery(() => db.syncQueue.count()) ?? 0 const {
pendingCount: pendingSyncCount,
showSpinner,
showPendingWarning,
connStatusClassName
} = useSyncIndicator()
const sharedLogbookCount = useLiveQuery( const sharedLogbookCount = useLiveQuery(
() => db.logbooks.filter((lb) => lb.isShared === 1).count(), () => db.logbooks.filter((lb) => lb.isShared === 1).count(),
@@ -476,6 +483,8 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
</dl> </dl>
</section> </section>
<UserProfilePreferences userId={profile.userId} />
<section className="member-editor-card glass"> <section className="member-editor-card glass">
<div className="profile-section-header"> <div className="profile-section-header">
<Shield size={20} /> <Shield size={20} />
@@ -524,11 +533,16 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
<h3>{t('profile.device_title')}</h3> <h3>{t('profile.device_title')}</h3>
</div> </div>
<p className="profile-section-desc">{t('profile.device_desc')}</p> <p className="profile-section-desc">{t('profile.device_desc')}</p>
<div className={`profile-device-status conn-status ${online ? (pendingSyncCount > 0 ? 'warning' : 'online') : 'offline'}`}> <div className={`profile-device-status ${connStatusClassName(online)}`}>
{online ? ( {online ? (
pendingSyncCount > 0 ? ( showSpinner ? (
<> <>
<RefreshCw size={16} className="spin" aria-hidden="true" /> <RefreshCw size={16} className="spin" aria-hidden="true" />
<span>{t('sync.status_syncing')}</span>
</>
) : showPendingWarning ? (
<>
<RefreshCw size={16} aria-hidden="true" />
<span>{t('profile.device_sync_pending', { count: pendingSyncCount })}</span> <span>{t('profile.device_sync_pending', { count: pendingSyncCount })}</span>
</> </>
) : ( ) : (
@@ -0,0 +1,155 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Compass, Palette, Save, Check, Cloud } from 'lucide-react'
import ThemedSelect from './ThemedSelect.tsx'
import PushNotificationSettings from './PushNotificationSettings.tsx'
import PwaInstallPrompt from './PwaInstallPrompt.tsx'
import { notifyAppearanceChanged } from '../services/appearance.js'
import { useAppTour } from '../context/AppTourContext.tsx'
import {
getColorSchemePreference,
getOwmApiKey,
getThemePreference,
setColorSchemePreference,
setOwmApiKey,
setThemePreference
} from '../services/userPreferences.js'
interface UserProfilePreferencesProps {
userId: string
}
export default function UserProfilePreferences({ userId }: UserProfilePreferencesProps) {
const { t } = useTranslation()
const { restartTour } = useAppTour()
const [apiKey, setApiKey] = useState(() => getOwmApiKey(userId))
const [theme, setTheme] = useState(() => getThemePreference(userId))
const [colorScheme, setColorScheme] = useState(() => getColorSchemePreference(userId))
const [savingOwm, setSavingOwm] = useState(false)
const [owmSaved, setOwmSaved] = useState(false)
const persistAppearance = (nextTheme: string, nextColorScheme: string) => {
setThemePreference(userId, nextTheme)
setColorSchemePreference(userId, nextColorScheme)
notifyAppearanceChanged()
}
const handleThemeChange = (nextTheme: string) => {
setTheme(nextTheme)
persistAppearance(nextTheme, colorScheme)
}
const handleColorSchemeChange = (nextColorScheme: string) => {
setColorScheme(nextColorScheme)
persistAppearance(theme, nextColorScheme)
}
const handleSaveOwm = (e: React.FormEvent) => {
e.preventDefault()
setSavingOwm(true)
setOwmSaved(false)
setOwmApiKey(userId, apiKey)
setSavingOwm(false)
setOwmSaved(true)
window.setTimeout(() => setOwmSaved(false), 3000)
}
return (
<>
<section className="member-editor-card glass">
<div className="profile-section-header">
<Palette size={20} />
<h3>{t('profile.appearance_title')}</h3>
</div>
<p className="profile-section-desc">{t('profile.appearance_desc')}</p>
<div className="input-group">
<label htmlFor="profile-app-theme" className="profile-field-label">
{t('profile.theme_label')}
</label>
<ThemedSelect
id="profile-app-theme"
value={theme}
onChange={handleThemeChange}
options={[
{ value: 'auto', label: t('profile.theme_auto') },
{ value: 'ocean', label: t('profile.theme_ocean') },
{ value: 'material', label: t('profile.theme_material') },
{ value: 'cupertino', label: t('profile.theme_cupertino') }
]}
/>
</div>
<div className="input-group mt-4">
<label htmlFor="profile-color-scheme" className="profile-field-label">
{t('profile.color_scheme_label')}
</label>
<ThemedSelect
id="profile-color-scheme"
value={colorScheme}
onChange={handleColorSchemeChange}
options={[
{ value: 'auto', label: t('profile.color_scheme_auto') },
{ value: 'light', label: t('profile.color_scheme_light') },
{ value: 'dark', label: t('profile.color_scheme_dark') }
]}
/>
</div>
</section>
<section className="member-editor-card glass">
<div className="profile-section-header">
<Compass size={20} />
<h3>{t('profile.tour_title')}</h3>
</div>
<p className="profile-section-desc">{t('profile.tour_desc')}</p>
<div className="form-actions">
<button type="button" className="btn secondary" onClick={() => restartTour()}>
{t('profile.tour_restart')}
</button>
</div>
</section>
<section className="member-editor-card glass">
<div className="profile-section-header">
<Cloud size={20} />
<h3>{t('profile.integrations_title')}</h3>
</div>
<p className="profile-section-desc">{t('profile.owm_help')}</p>
<form onSubmit={handleSaveOwm}>
<div className="input-group">
<label htmlFor="profile-owm-api-key" className="profile-field-label">
{t('profile.owm_key')}
</label>
<input
id="profile-owm-api-key"
name="owm-api-key"
type="password"
className="input-text"
placeholder="e.g. 8b6a7f...d8"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
disabled={savingOwm}
autoComplete="off"
/>
</div>
<div className="form-actions mt-4">
{owmSaved && (
<div className="success-toast">
<Check size={16} />
<span>{t('profile.prefs_saved')}</span>
</div>
)}
<button type="submit" className="btn primary" disabled={savingOwm}>
<Save size={18} />
{savingOwm ? t('profile.prefs_saving') : t('profile.prefs_save')}
</button>
</div>
</form>
</section>
<PushNotificationSettings />
<PwaInstallPrompt variant="inline" />
</>
)
}
+48
View File
@@ -0,0 +1,48 @@
import { useEffect, useState } from 'react'
import { useLiveQuery } from 'dexie-react-hooks'
import { db } from '../services/db.js'
import { subscribeToSyncState } from '../services/sync.js'
export type SyncConnStatusVariant = 'offline' | 'syncing' | 'pending' | 'online'
/** Maps sync/online state to conn-status CSS modifier classes. */
export function syncConnStatusClassName(
online: boolean,
showSpinner: boolean,
pendingCount: number
): string {
if (!online) return 'conn-status offline'
if (showSpinner) return 'conn-status syncing'
if (pendingCount > 0) return 'conn-status warning'
return 'conn-status online'
}
/** Sync queue depth and whether a sync pass is running (for header indicators). */
export function useSyncIndicator(logbookId?: string | null) {
const [isSyncing, setIsSyncing] = useState(false)
const pendingCount =
useLiveQuery(
() =>
logbookId
? db.syncQueue.where({ logbookId }).count()
: db.syncQueue.count(),
[logbookId]
) ?? 0
useEffect(() => {
return subscribeToSyncState(setIsSyncing)
}, [])
const showSpinner = isSyncing
const showPendingWarning = pendingCount > 0 && !isSyncing
return {
isSyncing,
pendingCount,
showSpinner,
showPendingWarning,
connStatusClassName: (online: boolean) =>
syncConnStatusClassName(online, showSpinner, pendingCount)
}
}
+37 -33
View File
@@ -23,7 +23,7 @@
}, },
"auth": { "auth": {
"welcome": "Willkommen bei Kapteins Daagbok", "welcome": "Willkommen bei Kapteins Daagbok",
"tagline": "Sicheres, E2E-verschlüsseltes maritimes Logbuch.", "tagline": "Dein sicheres, E2E-verschlüsseltes maritimes Logbuch.",
"register": "Mit Passkey registrieren", "register": "Mit Passkey registrieren",
"login": "Mit Passkey anmelden", "login": "Mit Passkey anmelden",
"login_as": "Anmelden als {{name}}", "login_as": "Anmelden als {{name}}",
@@ -84,6 +84,7 @@
}, },
"sync": { "sync": {
"status_synced": "Synchronisiert", "status_synced": "Synchronisiert",
"status_syncing": "Synchronisiere…",
"status_offline": "Offline-Cache", "status_offline": "Offline-Cache",
"status_unsynced": "Unsynchronisierte Änderungen" "status_unsynced": "Unsynchronisierte Änderungen"
}, },
@@ -222,6 +223,8 @@
"event_heel": "Krängung (°)", "event_heel": "Krängung (°)",
"event_sails": "Segelführung / Motor", "event_sails": "Segelführung / Motor",
"motor_propulsion": "Maschinenfahrt", "motor_propulsion": "Maschinenfahrt",
"sails_picker_show_more": "Alle Segel anzeigen",
"sails_picker_show_less": "Weniger anzeigen",
"motor_hours": "Maschinenstunden (gesamt)", "motor_hours": "Maschinenstunden (gesamt)",
"fuel_per_motor_hour": "Verbrauch pro Maschinenstunde", "fuel_per_motor_hour": "Verbrauch pro Maschinenstunde",
"event_distance": "Distanz (sm)", "event_distance": "Distanz (sm)",
@@ -380,7 +383,35 @@
"stats_subtitle": "Über alle deine Logbücher auf diesem Gerät", "stats_subtitle": "Über alle deine Logbücher auf diesem Gerät",
"stats_logbooks": "Logbücher", "stats_logbooks": "Logbücher",
"stats_account_since": "Konto seit", "stats_account_since": "Konto seit",
"stats_shared_logbooks": "Geteilte Logbücher" "stats_shared_logbooks": "Geteilte Logbücher",
"appearance_title": "App & Darstellung",
"appearance_desc": "Design und Farbschema gelten für die gesamte App auf diesem Gerät.",
"theme_label": "Design-Stil der App",
"theme_auto": "Automatisch (OS-Erkennung)",
"theme_ocean": "Ocean (Glassmorphismus)",
"theme_material": "Material (Android)",
"theme_cupertino": "Cupertino (iOS)",
"color_scheme_label": "Hell- oder Dunkelmodus",
"color_scheme_auto": "Automatisch (System)",
"color_scheme_light": "Hell",
"color_scheme_dark": "Dunkel",
"integrations_title": "Integrationen",
"owm_key": "OpenWeatherMap API-Schlüssel",
"owm_help": "Optional: eigener OpenWeatherMap-API-Schlüssel. Ohne Eintrag wird der serverseitige Schlüssel aus der Betreiber-Konfiguration verwendet.",
"prefs_save": "Speichern",
"prefs_saving": "Wird gespeichert…",
"prefs_saved": "Gespeichert",
"tour_title": "App-Tour",
"tour_desc": "Lass dich erneut durch die wichtigsten Bereiche der App führen.",
"tour_restart": "Tour erneut starten",
"push_title": "Push-Benachrichtigungen",
"push_desc": "Als Logbuch-Eigner wirst du benachrichtigt, wenn eingeladene Crewmitglieder Änderungen synchronisieren. Es werden keine Inhalte im Klartext übermittelt.",
"push_enable": "Bei Crew-Änderungen benachrichtigen",
"push_active": "Push-Benachrichtigungen sind auf diesem Gerät aktiv.",
"push_unsupported": "Push-Benachrichtigungen werden in diesem Browser nicht unterstützt.",
"push_denied_hint": "Benachrichtigungen sind blockiert. Erlaube sie in den Browser- oder Geräteeinstellungen.",
"push_ios_install_hint": "Auf dem iPhone/iPad: App zum Home-Bildschirm hinzufügen (iOS 16.4+), um Push zu nutzen.",
"push_error": "Push-Benachrichtigungen konnten nicht aktiviert werden."
}, },
"crew": { "crew": {
"title": "Skipper- & Crew-Profile", "title": "Skipper- & Crew-Profile",
@@ -417,30 +448,14 @@
"loading": "Kalibrierungstabelle wird geladen..." "loading": "Kalibrierungstabelle wird geladen..."
}, },
"settings": { "settings": {
"title": "Systemeinstellungen", "title": "Logbuch-Einstellungen",
"subtitle": "Konfiguriere externe Integrationen und Anmeldedaten.", "subtitle": "Teilen, Backup und Zusammenarbeit für dieses Logbuch.",
"owm_title": "Wetter-Integration", "select_logbook_hint": "Wähle ein Logbuch aus, um dessen Einstellungen zu bearbeiten.",
"owm_key": "OpenWeatherMap API-Schlüssel", "no_key": "Kein OpenWeatherMap-API-Schlüssel verfügbar. Hinterlege einen eigenen Schlüssel im Benutzerprofil oder kontaktiere den Betreiber.",
"save": "Konfiguration speichern",
"saving": "Wird gespeichert...",
"saved": "Einstellungen erfolgreich gespeichert!",
"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. Hinterlege einen eigenen Schlüssel in den Einstellungen oder kontaktiere den Betreiber.",
"weather_success": "Wetterdaten erfolgreich abgerufen!", "weather_success": "Wetterdaten erfolgreich abgerufen!",
"weather_error": "Wetterdatenabruf fehlgeschlagen. Überprüfe den API-Schlüssel und die Verbindung.", "weather_error": "Wetterdatenabruf fehlgeschlagen. Überprüfe den API-Schlüssel und die Verbindung.",
"weather_date_mismatch": "Wetterdaten können nur für den heutigen Tag ({{today}}) abgerufen werden. Dieser Logbucheintrag ist auf den {{date}} datiert.", "weather_date_mismatch": "Wetterdaten können nur für den heutigen Tag ({{today}}) abgerufen werden. Dieser Logbucheintrag ist auf den {{date}} datiert.",
"gps_error": "Bitte gib einen Ort an oder ermittle die GPS-Koordinaten.", "gps_error": "Bitte gib einen Ort an oder ermittle die GPS-Koordinaten.",
"theme_title": "Design-Anpassung",
"theme_label": "Design-Stil der App",
"theme_auto": "Automatisch (OS-Erkennung)",
"theme_ocean": "Ocean (Glassmorphismus)",
"theme_material": "Material (Android)",
"theme_cupertino": "Cupertino (iOS)",
"color_scheme_title": "Erscheinungsbild",
"color_scheme_label": "Hell- oder Dunkelmodus (Standard: Systemeinstellung)",
"color_scheme_auto": "Automatisch (System)",
"color_scheme_light": "Hell",
"color_scheme_dark": "Dunkel",
"share_title": "Logbuch teilen (Schreibgeschützt)", "share_title": "Logbuch teilen (Schreibgeschützt)",
"share_desc": "Aktiviere diese Option, um einen öffentlichen, schreibgeschützten Link zu erstellen. Jeder mit dem Link kann deine Reisen, Yacht-Profile und Besatzung ansehen. Die Verschlüsselungsschlüssel werden niemals an den Server übertragen (sie bleiben im Hash-Teil der URL).", "share_desc": "Aktiviere diese Option, um einen öffentlichen, schreibgeschützten Link zu erstellen. Jeder mit dem Link kann deine Reisen, Yacht-Profile und Besatzung ansehen. Die Verschlüsselungsschlüssel werden niemals an den Server übertragen (sie bleiben im Hash-Teil der URL).",
"share_privacy_warning": "Empfehlung: Teile diesen Link nur privat (z. B. per E-Mail oder Messenger), nicht in sozialen Medien.", "share_privacy_warning": "Empfehlung: Teile diesen Link nur privat (z. B. per E-Mail oder Messenger), nicht in sozialen Medien.",
@@ -457,17 +472,6 @@
"delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuche es erneut.", "delete_account_failed": "Konto konnte nicht gelöscht werden. Bitte versuche es erneut.",
"delete_backup_hint": "Tipp: Erstelle vor dem Löschen Backups deiner Logbücher (.daagbok.json) in den Einstellungen jedes Logbuchs.", "delete_backup_hint": "Tipp: Erstelle vor dem Löschen Backups deiner Logbücher (.daagbok.json) in den Einstellungen jedes Logbuchs.",
"deleting_account": "Konto wird gelöscht…", "deleting_account": "Konto wird gelöscht…",
"tour_title": "App-Tour",
"tour_desc": "Lass dich erneut durch die wichtigsten Bereiche der App führen.",
"tour_restart": "Tour erneut starten",
"push_title": "Push-Benachrichtigungen",
"push_desc": "Als Logbuch-Eigner wirst du benachrichtigt, wenn eingeladene Crewmitglieder Änderungen synchronisieren. Es werden keine Inhalte im Klartext übermittelt.",
"push_enable": "Bei Crew-Änderungen benachrichtigen",
"push_active": "Push-Benachrichtigungen sind auf diesem Gerät aktiv.",
"push_unsupported": "Push-Benachrichtigungen werden in diesem Browser nicht unterstützt.",
"push_denied_hint": "Benachrichtigungen sind blockiert. Erlaube sie in den Browser- oder Geräteeinstellungen.",
"push_ios_install_hint": "Auf dem iPhone/iPad: App zum Home-Bildschirm hinzufügen (iOS 16.4+), um Push zu nutzen.",
"push_error": "Push-Benachrichtigungen konnten nicht aktiviert werden.",
"backup_title": "Backup & Wiederherstellung", "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_desc": "Vollständiges verschlüsseltes Backup dieses Logbuchs (Einträge, Fotos, GPS-Tracks, Crew, Schiff). Mit Backup-Passphrase geschützt — für Restore auf diesem oder einem neuen Account.",
"backup_export_title": "Backup erstellen", "backup_export_title": "Backup erstellen",
+36 -32
View File
@@ -84,6 +84,7 @@
}, },
"sync": { "sync": {
"status_synced": "Synced", "status_synced": "Synced",
"status_syncing": "Syncing…",
"status_offline": "Offline Cache", "status_offline": "Offline Cache",
"status_unsynced": "Unsynced changes" "status_unsynced": "Unsynced changes"
}, },
@@ -222,6 +223,8 @@
"event_heel": "Heel Angle (°)", "event_heel": "Heel Angle (°)",
"event_sails": "Sails / Motor Status", "event_sails": "Sails / Motor Status",
"motor_propulsion": "Engine Propulsion", "motor_propulsion": "Engine Propulsion",
"sails_picker_show_more": "Show all sails",
"sails_picker_show_less": "Show less",
"motor_hours": "Engine hours (total)", "motor_hours": "Engine hours (total)",
"fuel_per_motor_hour": "Consumption per engine hour", "fuel_per_motor_hour": "Consumption per engine hour",
"event_distance": "Distance (nm)", "event_distance": "Distance (nm)",
@@ -380,7 +383,35 @@
"stats_subtitle": "Across all your logbooks on this device", "stats_subtitle": "Across all your logbooks on this device",
"stats_logbooks": "Logbooks", "stats_logbooks": "Logbooks",
"stats_account_since": "Account since", "stats_account_since": "Account since",
"stats_shared_logbooks": "Shared logbooks" "stats_shared_logbooks": "Shared logbooks",
"appearance_title": "App & appearance",
"appearance_desc": "Theme and color scheme apply to the entire app on this device.",
"theme_label": "Application style / theme",
"theme_auto": "Auto (OS detect)",
"theme_ocean": "Ocean (glassmorphism)",
"theme_material": "Material (Android)",
"theme_cupertino": "Cupertino (iOS)",
"color_scheme_label": "Light or dark mode",
"color_scheme_auto": "Auto (system)",
"color_scheme_light": "Light",
"color_scheme_dark": "Dark",
"integrations_title": "Integrations",
"owm_key": "OpenWeatherMap API key",
"owm_help": "Optional: your own OpenWeatherMap API key. If left empty, the operator-configured server key is used.",
"prefs_save": "Save",
"prefs_saving": "Saving…",
"prefs_saved": "Saved",
"tour_title": "App tour",
"tour_desc": "Take a guided walkthrough of the main areas of the app again.",
"tour_restart": "Restart tour",
"push_title": "Push notifications",
"push_desc": "As logbook owner you are notified when invited crew members sync changes. No logbook content is sent in plain text.",
"push_enable": "Notify on crew changes",
"push_active": "Push notifications are active on this device.",
"push_unsupported": "Push notifications are not supported in this browser.",
"push_denied_hint": "Notifications are blocked. Allow them in your browser or device settings.",
"push_ios_install_hint": "On iPhone/iPad: add the app to your Home Screen (iOS 16.4+) to use push notifications.",
"push_error": "Could not enable push notifications."
}, },
"crew": { "crew": {
"title": "Skipper & Crew Profiles", "title": "Skipper & Crew Profiles",
@@ -417,30 +448,14 @@
"loading": "Loading calibration table..." "loading": "Loading calibration table..."
}, },
"settings": { "settings": {
"title": "System Settings", "title": "Logbook settings",
"subtitle": "Configure external integrations and client credentials.", "subtitle": "Sharing, backup, and collaboration for this logbook.",
"owm_title": "Weather Integration", "select_logbook_hint": "Select a logbook to edit its settings.",
"owm_key": "OpenWeatherMap API Key", "no_key": "No OpenWeatherMap API key available. Add your own key in your user profile or contact the operator.",
"save": "Save Configuration",
"saving": "Saving...",
"saved": "Settings saved successfully!",
"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_success": "Weather details fetched successfully!",
"weather_error": "Failed to fetch weather. Check your API key and connection.", "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}}.", "weather_date_mismatch": "Weather data can only be fetched for today ({{today}}). This logbook entry is dated {{date}}.",
"gps_error": "Please enter a location or fetch GPS coordinates first.", "gps_error": "Please enter a location or fetch GPS coordinates first.",
"theme_title": "UI Customization",
"theme_label": "Application Style / Theme",
"theme_auto": "Auto (OS Detect)",
"theme_ocean": "Ocean (Glassmorphism)",
"theme_material": "Material (Android)",
"theme_cupertino": "Cupertino (iOS)",
"color_scheme_title": "Appearance",
"color_scheme_label": "Light or dark mode (default: follow system)",
"color_scheme_auto": "Auto (System)",
"color_scheme_light": "Light",
"color_scheme_dark": "Dark",
"share_title": "Share Logbook (Read-Only)", "share_title": "Share Logbook (Read-Only)",
"share_desc": "Enable this to generate a public, read-only link. Anyone with the link can view your travels, yacht profile, and crew members. Decryption keys are never transmitted to the server (they stay in the hash part of the URL).", "share_desc": "Enable this to generate a public, read-only link. Anyone with the link can view your travels, yacht profile, and crew members. Decryption keys are never transmitted to the server (they stay in the hash part of the URL).",
"share_privacy_warning": "Recommendation: Share this link only privately (e.g. via email or messenger), not on social media.", "share_privacy_warning": "Recommendation: Share this link only privately (e.g. via email or messenger), not on social media.",
@@ -457,17 +472,6 @@
"delete_account_failed": "Failed to delete account. Please try again.", "delete_account_failed": "Failed to delete account. Please try again.",
"delete_backup_hint": "Tip: Before deleting, create backups of your logbooks (.daagbok.json) in each logbook's settings.", "delete_backup_hint": "Tip: Before deleting, create backups of your logbooks (.daagbok.json) in each logbook's settings.",
"deleting_account": "Deleting account…", "deleting_account": "Deleting account…",
"tour_title": "App tour",
"tour_desc": "Take a guided walkthrough of the main areas of the app again.",
"tour_restart": "Restart tour",
"push_title": "Push notifications",
"push_desc": "As logbook owner you are notified when invited crew members sync changes. No logbook content is sent in plain text.",
"push_enable": "Notify on crew changes",
"push_active": "Push notifications are active on this device.",
"push_unsupported": "Push notifications are not supported in this browser.",
"push_denied_hint": "Notifications are blocked. Allow them in your browser or device settings.",
"push_ios_install_hint": "On iPhone/iPad: add the app to your Home Screen (iOS 16.4+) to use push notifications.",
"push_error": "Could not enable push notifications.",
"backup_title": "Backup & restore", "backup_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_desc": "Full encrypted backup of this logbook (entries, photos, GPS tracks, crew, vessel). Protected with a backup passphrase — restore on this or a new account.",
"backup_export_title": "Create backup", "backup_export_title": "Create backup",
+70
View File
@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
applyAppearanceToDocument,
resolveAppTheme,
resolveColorScheme,
type AppTheme,
type ResolvedColorScheme
} from './appearance.js'
import { setColorSchemePreference } from './userPreferences.js'
const USER_ID = 'appearance-test-user'
const COMBOS: Array<{ theme: AppTheme; scheme: ResolvedColorScheme }> = [
{ theme: 'ocean', scheme: 'dark' },
{ theme: 'ocean', scheme: 'light' },
{ theme: 'material', scheme: 'dark' },
{ theme: 'material', scheme: 'light' },
{ theme: 'cupertino', scheme: 'dark' },
{ theme: 'cupertino', scheme: 'light' }
]
describe('appearance', () => {
beforeEach(() => {
localStorage.clear()
document.documentElement.className = ''
document.documentElement.style.colorScheme = ''
document.head.querySelector('meta[name="theme-color"]')?.remove()
})
it.each(COMBOS)('applies $theme · $scheme classes to document', ({ theme, scheme }) => {
applyAppearanceToDocument(theme, scheme)
const root = document.documentElement
expect(root.classList.contains(`theme-${theme}`)).toBe(true)
expect(root.classList.contains(`scheme-${scheme}`)).toBe(true)
expect(root.style.colorScheme).toBe(scheme)
})
it('replaces previous theme classes when switching appearance', () => {
applyAppearanceToDocument('ocean', 'dark')
applyAppearanceToDocument('material', 'light')
const root = document.documentElement
expect(root.classList.contains('theme-material')).toBe(true)
expect(root.classList.contains('theme-ocean')).toBe(false)
expect(root.classList.contains('scheme-light')).toBe(true)
expect(root.classList.contains('scheme-dark')).toBe(false)
})
it('resolves stored light scheme even when system prefers dark', () => {
vi.stubGlobal(
'matchMedia',
vi.fn().mockReturnValue({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() })
)
localStorage.setItem('active_userid', USER_ID)
setColorSchemePreference(USER_ID, 'light')
expect(resolveColorScheme()).toBe('light')
applyAppearanceToDocument('material', resolveColorScheme())
expect(document.documentElement.classList.contains('scheme-light')).toBe(true)
})
it('auto theme picks material on Android user agent', () => {
vi.stubGlobal('navigator', {
...navigator,
userAgent: 'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36'
})
expect(resolveAppTheme()).toBe('material')
})
})
+17 -2
View File
@@ -1,3 +1,5 @@
import { getColorSchemePreference as getStoredColorScheme, getThemePreference } from './userPreferences.js'
export type ColorSchemePreference = 'auto' | 'light' | 'dark' export type ColorSchemePreference = 'auto' | 'light' | 'dark'
export type ResolvedColorScheme = 'light' | 'dark' export type ResolvedColorScheme = 'light' | 'dark'
export type AppTheme = 'ocean' | 'material' | 'cupertino' export type AppTheme = 'ocean' | 'material' | 'cupertino'
@@ -6,7 +8,7 @@ const THEME_CLASSES = ['theme-ocean', 'theme-material', 'theme-cupertino'] as co
const SCHEME_CLASSES = ['scheme-light', 'scheme-dark'] as const const SCHEME_CLASSES = ['scheme-light', 'scheme-dark'] as const
export function getColorSchemePreference(): ColorSchemePreference { export function getColorSchemePreference(): ColorSchemePreference {
const stored = localStorage.getItem('active_color_scheme') const stored = getStoredColorScheme()
if (stored === 'light' || stored === 'dark' || stored === 'auto') return stored if (stored === 'light' || stored === 'dark' || stored === 'auto') return stored
return 'auto' return 'auto'
} }
@@ -19,7 +21,7 @@ export function resolveColorScheme(pref?: ColorSchemePreference): ResolvedColorS
} }
export function resolveAppTheme(): AppTheme { export function resolveAppTheme(): AppTheme {
const configTheme = localStorage.getItem('active_theme') || 'auto' const configTheme = getThemePreference() || 'auto'
if (configTheme === 'material' || configTheme === 'cupertino' || configTheme === 'ocean') { if (configTheme === 'material' || configTheme === 'cupertino' || configTheme === 'ocean') {
return configTheme return configTheme
} }
@@ -29,6 +31,18 @@ export function resolveAppTheme(): AppTheme {
return 'ocean' return 'ocean'
} }
function updateThemeColorMeta(root: HTMLElement): void {
const color = getComputedStyle(root).getPropertyValue('--app-theme-color').trim()
if (!color) return
let meta = document.querySelector('meta[name="theme-color"]')
if (!meta) {
meta = document.createElement('meta')
meta.setAttribute('name', 'theme-color')
document.head.appendChild(meta)
}
meta.setAttribute('content', color)
}
export function applyAppearanceToDocument( export function applyAppearanceToDocument(
theme: AppTheme = resolveAppTheme(), theme: AppTheme = resolveAppTheme(),
scheme: ResolvedColorScheme = resolveColorScheme() scheme: ResolvedColorScheme = resolveColorScheme()
@@ -37,6 +51,7 @@ export function applyAppearanceToDocument(
root.classList.remove(...THEME_CLASSES, ...SCHEME_CLASSES) root.classList.remove(...THEME_CLASSES, ...SCHEME_CLASSES)
root.classList.add(`theme-${theme}`, `scheme-${scheme}`) root.classList.add(`theme-${theme}`, `scheme-${scheme}`)
root.style.colorScheme = scheme root.style.colorScheme = scheme
updateThemeColorMeta(root)
} }
export function subscribeToSystemColorScheme(onChange: () => void): () => void { export function subscribeToSystemColorScheme(onChange: () => void): () => void {
+14 -6
View File
@@ -46,13 +46,21 @@ export async function checkServerSession(): Promise<{ authenticated: boolean; us
} }
} }
/** Master key is memory-only; after process kill the HTTP session may outlive local crypto state. */ /** Master key + username in memory/storage — enough to stay in the unlocked UI. */
export function hasUnlockedLocalCrypto(): boolean {
return !!(getActiveMasterKey() && localStorage.getItem('active_username'))
}
/** Crypto unlock plus user id for authenticated API calls (userId may already be in localStorage). */
export function hasUnlockedLocalSession(): boolean { export function hasUnlockedLocalSession(): boolean {
return !!( return hasUnlockedLocalCrypto() && !!localStorage.getItem('active_userid')
getActiveMasterKey() && }
localStorage.getItem('active_username') &&
localStorage.getItem('active_userid') /** Persist server session user id when the /session response includes it. */
) export function persistSessionUserId(userId: string | undefined): void {
if (userId) {
localStorage.setItem('active_userid', userId)
}
} }
export async function reauthWithPasskey(): Promise<boolean> { export async function reauthWithPasskey(): Promise<boolean> {
+53
View File
@@ -0,0 +1,53 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
hasUnlockedLocalCrypto,
hasUnlockedLocalSession,
setActiveMasterKey
} from './auth.js'
describe('local session unlock checks', () => {
beforeEach(() => {
localStorage.clear()
setActiveMasterKey(null)
})
it('hasUnlockedLocalCrypto with master key and username only', () => {
setActiveMasterKey(new ArrayBuffer(32))
localStorage.setItem('active_username', 'skipper')
expect(hasUnlockedLocalCrypto()).toBe(true)
expect(hasUnlockedLocalSession()).toBe(false)
})
it('hasUnlockedLocalSession when userId is present', () => {
setActiveMasterKey(new ArrayBuffer(32))
localStorage.setItem('active_username', 'skipper')
localStorage.setItem('active_userid', 'user-1')
expect(hasUnlockedLocalCrypto()).toBe(true)
expect(hasUnlockedLocalSession()).toBe(true)
})
it('hasUnlockedLocalCrypto false without master key', () => {
localStorage.setItem('active_username', 'skipper')
localStorage.setItem('active_userid', 'user-1')
expect(hasUnlockedLocalCrypto()).toBe(false)
})
})
describe('persistSessionUserId', () => {
beforeEach(() => {
localStorage.clear()
})
it('stores userId when provided', async () => {
const { persistSessionUserId } = await import('./auth.js')
persistSessionUserId('user-42')
expect(localStorage.getItem('active_userid')).toBe('user-42')
})
it('does not clear existing userId when omitted', async () => {
const { persistSessionUserId } = await import('./auth.js')
localStorage.setItem('active_userid', 'user-1')
persistSessionUserId(undefined)
expect(localStorage.getItem('active_userid')).toBe('user-1')
})
})
+59 -5
View File
@@ -6,6 +6,7 @@ import { getLogbookAccess } from './logbookAccess.js'
const API_BASE = '/api/sync' const API_BASE = '/api/sync'
const syncingLogbooks = new Set<string>() const syncingLogbooks = new Set<string>()
const pendingResync = new Set<string>() const pendingResync = new Set<string>()
let syncAllInFlight = 0
let isSyncing = false let isSyncing = false
const listeners = new Set<(syncing: boolean) => void>() const listeners = new Set<(syncing: boolean) => void>()
@@ -18,7 +19,8 @@ export function subscribeToSyncState(listener: (syncing: boolean) => void) {
} }
} }
function setSyncing(syncing: boolean) { function recomputeSyncingState() {
const syncing = syncingLogbooks.size > 0 || syncAllInFlight > 0
if (isSyncing !== syncing) { if (isSyncing !== syncing) {
isSyncing = syncing isSyncing = syncing
listeners.forEach((l) => l(isSyncing)) listeners.forEach((l) => l(isSyncing))
@@ -205,6 +207,54 @@ async function flushPushQueue(logbookId: string): Promise<boolean> {
return ok return ok
} }
type PulledServerPayload = {
yacht?: { updatedAt: string } | null
deviation?: { updatedAt: string } | null
crews?: Array<{ payloadId: string; updatedAt: string }>
entries?: Array<{ payloadId: string; updatedAt: string }>
photos?: Array<{ payloadId: string; updatedAt: string }>
gpsTracks?: Array<{ entryId: string; updatedAt: string }>
}
/** Drop queue rows already reflected on the server (e.g. after direct API save). */
async function pruneAcknowledgedQueueItems(
logbookId: string,
server: PulledServerPayload
): Promise<void> {
const pending = await db.syncQueue.where({ logbookId }).toArray()
if (pending.length === 0) return
const serverTimes = new Map<string, string>()
if (server.yacht) serverTimes.set('yacht:' + logbookId, server.yacht.updatedAt)
if (server.deviation) serverTimes.set('deviation:' + logbookId, server.deviation.updatedAt)
for (const c of server.crews ?? []) serverTimes.set('crew:' + c.payloadId, c.updatedAt)
for (const e of server.entries ?? []) serverTimes.set('entry:' + e.payloadId, e.updatedAt)
for (const p of server.photos ?? []) serverTimes.set('photo:' + p.payloadId, p.updatedAt)
for (const gt of server.gpsTracks ?? []) serverTimes.set('gpsTrack:' + gt.entryId, gt.updatedAt)
const localLogbook = await db.logbooks.get(logbookId)
const staleIds: number[] = []
for (const item of pending) {
if (item.type === 'logbook') {
if (localLogbook?.isSynced === 1) {
if (item.id !== undefined) staleIds.push(item.id)
}
continue
}
const key = item.type === 'yacht' ? 'yacht:' + logbookId : `${item.type}:${item.payloadId}`
const serverUpdatedAt = serverTimes.get(key)
if (serverUpdatedAt && !isNewer(item.updatedAt, serverUpdatedAt)) {
if (item.id !== undefined) staleIds.push(item.id)
}
}
if (staleIds.length > 0) {
await db.syncQueue.bulkDelete(staleIds)
}
}
// Pull updates from the server and apply last-write-wins // Pull updates from the server and apply last-write-wins
async function pullChanges(logbookId: string): Promise<boolean> { async function pullChanges(logbookId: string): Promise<boolean> {
if (!localStorage.getItem('active_userid')) return false if (!localStorage.getItem('active_userid')) return false
@@ -220,6 +270,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
} }
const { yacht, deviation, crews, entries, photos, gpsTracks } = await response.json() const { yacht, deviation, crews, entries, photos, gpsTracks } = await response.json()
const serverSnapshot: PulledServerPayload = { yacht, deviation, crews, entries, photos, gpsTracks }
// 1. Sync Yacht Payload // 1. Sync Yacht Payload
if (yacht) { if (yacht) {
@@ -380,6 +431,7 @@ async function pullChanges(logbookId: string): Promise<boolean> {
} }
} }
await pruneAcknowledgedQueueItems(logbookId, serverSnapshot)
return true return true
} catch (error) { } catch (error) {
console.error('Error during sync pull:', error) console.error('Error during sync pull:', error)
@@ -400,7 +452,7 @@ export async function syncLogbook(logbookId: string): Promise<boolean> {
} }
syncingLogbooks.add(logbookId) syncingLogbooks.add(logbookId)
setSyncing(true) recomputeSyncingState()
try { try {
const pushed = await flushPushQueue(logbookId) const pushed = await flushPushQueue(logbookId)
@@ -410,7 +462,7 @@ export async function syncLogbook(logbookId: string): Promise<boolean> {
return pushed && pulled && pushedAfterPull return pushed && pulled && pushedAfterPull
} finally { } finally {
syncingLogbooks.delete(logbookId) syncingLogbooks.delete(logbookId)
setSyncing(syncingLogbooks.size > 0) recomputeSyncingState()
} }
} }
@@ -421,8 +473,9 @@ export async function syncAllLogbooks(): Promise<void> {
const masterKey = getActiveMasterKey() const masterKey = getActiveMasterKey()
if (!masterKey) return if (!masterKey) return
syncAllInFlight++
recomputeSyncingState()
try { try {
setSyncing(true)
// 1. Fetch latest logbook lists first (synchronizes db.logbooks index) // 1. Fetch latest logbook lists first (synchronizes db.logbooks index)
const logbooks = await db.logbooks.toArray() const logbooks = await db.logbooks.toArray()
@@ -446,7 +499,8 @@ export async function syncAllLogbooks(): Promise<void> {
} catch (error) { } catch (error) {
console.error('Error synchronizing all logbooks:', error) console.error('Error synchronizing all logbooks:', error)
} finally { } finally {
setSyncing(syncingLogbooks.size > 0) syncAllInFlight = Math.max(0, syncAllInFlight - 1)
recomputeSyncingState()
} }
} }
@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
getColorSchemePreference,
getOwmApiKey,
getOwmApiKeyForActiveUser,
getThemePreference,
setColorSchemePreference,
setOwmApiKey,
setThemePreference
} from './userPreferences.js'
const USER_ID = 'test-user-123'
describe('userPreferences', () => {
beforeEach(() => {
localStorage.clear()
})
it('migrates legacy theme and color scheme keys on first read', () => {
localStorage.setItem('active_userid', USER_ID)
localStorage.setItem('active_theme', 'material')
localStorage.setItem('active_color_scheme', 'dark')
expect(getThemePreference()).toBe('material')
expect(getColorSchemePreference()).toBe('dark')
expect(localStorage.getItem(`user_pref_theme_${USER_ID}`)).toBe('material')
expect(localStorage.getItem(`user_pref_color_scheme_${USER_ID}`)).toBe('dark')
})
it('stores OWM key per user', () => {
setOwmApiKey(USER_ID, 'secret-key')
expect(getOwmApiKey(USER_ID)).toBe('secret-key')
setOwmApiKey(USER_ID, ' ')
expect(getOwmApiKey(USER_ID)).toBe('')
})
it('reads namespaced OWM key via active user id', () => {
setOwmApiKey(USER_ID, 'namespaced-only')
localStorage.setItem('active_userid', USER_ID)
localStorage.removeItem('owm_api_key')
expect(getOwmApiKeyForActiveUser()).toBe('namespaced-only')
expect(getOwmApiKey()).toBe('namespaced-only')
})
it('does not read namespaced OWM key without active user id', () => {
setOwmApiKey(USER_ID, 'namespaced-only')
localStorage.removeItem('active_userid')
localStorage.removeItem('owm_api_key')
expect(getOwmApiKeyForActiveUser()).toBe('')
expect(getOwmApiKey()).toBe('')
})
it('writes theme preferences to namespaced keys', () => {
setThemePreference(USER_ID, 'ocean')
setColorSchemePreference(USER_ID, 'light')
expect(getThemePreference(USER_ID)).toBe('ocean')
expect(getColorSchemePreference(USER_ID)).toBe('light')
})
})
+91
View File
@@ -0,0 +1,91 @@
const LEGACY_THEME = 'active_theme'
const LEGACY_COLOR_SCHEME = 'active_color_scheme'
const LEGACY_OWM_KEY = 'owm_api_key'
function themeKey(userId: string): string {
return `user_pref_theme_${userId}`
}
function colorSchemeKey(userId: string): string {
return `user_pref_color_scheme_${userId}`
}
function owmKey(userId: string): string {
return `user_pref_owm_api_key_${userId}`
}
export function getActiveUserId(): string | null {
return localStorage.getItem('active_userid')
}
function migrateLegacyPrefs(userId: string): void {
const pairs: Array<{ namespaced: string; legacy: string }> = [
{ namespaced: themeKey(userId), legacy: LEGACY_THEME },
{ namespaced: colorSchemeKey(userId), legacy: LEGACY_COLOR_SCHEME },
{ namespaced: owmKey(userId), legacy: LEGACY_OWM_KEY }
]
for (const { namespaced, legacy } of pairs) {
if (localStorage.getItem(namespaced) != null) continue
const value = localStorage.getItem(legacy)
if (value != null) {
localStorage.setItem(namespaced, value)
}
}
}
function resolveUserId(userId?: string | null): string | null {
const id = (userId?.trim() || getActiveUserId()?.trim()) || null
if (!id) return null
migrateLegacyPrefs(id)
return id
}
export function getThemePreference(userId?: string | null): string {
const id = resolveUserId(userId)
if (id) {
return localStorage.getItem(themeKey(id)) ?? localStorage.getItem(LEGACY_THEME) ?? 'auto'
}
return localStorage.getItem(LEGACY_THEME) ?? 'auto'
}
export function setThemePreference(userId: string, value: string): void {
migrateLegacyPrefs(userId)
localStorage.setItem(themeKey(userId), value)
}
export function getColorSchemePreference(userId?: string | null): string {
const id = resolveUserId(userId)
if (id) {
return localStorage.getItem(colorSchemeKey(id)) ?? localStorage.getItem(LEGACY_COLOR_SCHEME) ?? 'auto'
}
return localStorage.getItem(LEGACY_COLOR_SCHEME) ?? 'auto'
}
export function setColorSchemePreference(userId: string, value: string): void {
migrateLegacyPrefs(userId)
localStorage.setItem(colorSchemeKey(userId), value)
}
export function getOwmApiKey(userId?: string | null): string {
const id = resolveUserId(userId)
if (id) {
return localStorage.getItem(owmKey(id)) ?? localStorage.getItem(LEGACY_OWM_KEY) ?? ''
}
return localStorage.getItem(LEGACY_OWM_KEY) ?? ''
}
/** OWM key for the signed-in user (`active_userid`). Prefer this over a bare `getOwmApiKey()` call. */
export function getOwmApiKeyForActiveUser(): string {
return getOwmApiKey(getActiveUserId())
}
export function setOwmApiKey(userId: string, value: string): void {
migrateLegacyPrefs(userId)
const trimmed = value.trim()
if (trimmed) {
localStorage.setItem(owmKey(userId), trimmed)
} else {
localStorage.removeItem(owmKey(userId))
}
}
+2 -1
View File
@@ -1,4 +1,5 @@
import { apiFetch } from './api.js' import { apiFetch } from './api.js'
import { getOwmApiKeyForActiveUser } from './userPreferences.js'
export class WeatherApiError extends Error { export class WeatherApiError extends Error {
code: 'NO_KEY' | 'REQUEST_FAILED' code: 'NO_KEY' | 'REQUEST_FAILED'
@@ -26,7 +27,7 @@ export async function fetchOpenWeatherCurrent(params: {
throw new WeatherApiError('lat/lon or location query required') throw new WeatherApiError('lat/lon or location query required')
} }
const userKey = localStorage.getItem('owm_api_key')?.trim() const userKey = getOwmApiKeyForActiveUser().trim()
const headers: Record<string, string> = {} const headers: Record<string, string> = {}
if (userKey) headers['X-OWM-Api-Key'] = userKey if (userKey) headers['X-OWM-Api-Key'] = userKey
+7
View File
@@ -6,6 +6,7 @@
/* Fallback before JS hydrates (ocean · dark) */ /* Fallback before JS hydrates (ocean · dark) */
html { html {
color-scheme: dark; color-scheme: dark;
--app-theme-color: #0b0c10;
--app-body-bg: radial-gradient(circle at center, #1b264f 0%, #0b0c10 100%); --app-body-bg: radial-gradient(circle at center, #1b264f 0%, #0b0c10 100%);
--app-text: #f1f5f9; --app-text: #f1f5f9;
--app-text-heading: #f8fafc; --app-text-heading: #f8fafc;
@@ -61,6 +62,7 @@ html {
/* ===== OCEAN · DARK (default) ===== */ /* ===== OCEAN · DARK (default) ===== */
html.scheme-dark.theme-ocean { html.scheme-dark.theme-ocean {
color-scheme: dark; color-scheme: dark;
--app-theme-color: #0b0c10;
--app-body-bg: radial-gradient(circle at center, #1b264f 0%, #0b0c10 100%); --app-body-bg: radial-gradient(circle at center, #1b264f 0%, #0b0c10 100%);
--app-text: #f1f5f9; --app-text: #f1f5f9;
--app-text-heading: #f8fafc; --app-text-heading: #f8fafc;
@@ -116,6 +118,7 @@ html.scheme-dark.theme-ocean {
/* ===== OCEAN · LIGHT ===== */ /* ===== OCEAN · LIGHT ===== */
html.scheme-light.theme-ocean { html.scheme-light.theme-ocean {
color-scheme: light; color-scheme: light;
--app-theme-color: #e2e8f0;
--app-body-bg: linear-gradient(165deg, #dbeafe 0%, #f8fafc 42%, #e2e8f0 100%); --app-body-bg: linear-gradient(165deg, #dbeafe 0%, #f8fafc 42%, #e2e8f0 100%);
--app-text: #1e293b; --app-text: #1e293b;
--app-text-heading: #0f172a; --app-text-heading: #0f172a;
@@ -171,6 +174,7 @@ html.scheme-light.theme-ocean {
/* ===== MATERIAL · DARK ===== */ /* ===== MATERIAL · DARK ===== */
html.scheme-dark.theme-material { html.scheme-dark.theme-material {
color-scheme: dark; color-scheme: dark;
--app-theme-color: #121212;
--app-body-bg: #121212; --app-body-bg: #121212;
--app-text: #f1f5f9; --app-text: #f1f5f9;
--app-text-heading: #f8fafc; --app-text-heading: #f8fafc;
@@ -226,6 +230,7 @@ html.scheme-dark.theme-material {
/* ===== MATERIAL · LIGHT ===== */ /* ===== MATERIAL · LIGHT ===== */
html.scheme-light.theme-material { html.scheme-light.theme-material {
color-scheme: light; color-scheme: light;
--app-theme-color: #fafafa;
--app-body-bg: #fafafa; --app-body-bg: #fafafa;
--app-text: #212121; --app-text: #212121;
--app-text-heading: #111827; --app-text-heading: #111827;
@@ -281,6 +286,7 @@ html.scheme-light.theme-material {
/* ===== CUPERTINO · DARK ===== */ /* ===== CUPERTINO · DARK ===== */
html.scheme-dark.theme-cupertino { html.scheme-dark.theme-cupertino {
color-scheme: dark; color-scheme: dark;
--app-theme-color: #000000;
--app-body-bg: #000000; --app-body-bg: #000000;
--app-text: #ffffff; --app-text: #ffffff;
--app-text-heading: #ffffff; --app-text-heading: #ffffff;
@@ -336,6 +342,7 @@ html.scheme-dark.theme-cupertino {
/* ===== CUPERTINO · LIGHT ===== */ /* ===== CUPERTINO · LIGHT ===== */
html.scheme-light.theme-cupertino { html.scheme-light.theme-cupertino {
color-scheme: light; color-scheme: light;
--app-theme-color: #f2f2f7;
--app-body-bg: #f2f2f7; --app-body-bg: #f2f2f7;
--app-text: #1c1c1e; --app-text: #1c1c1e;
--app-text-heading: #000000; --app-text-heading: #000000;
+3 -2
View File
@@ -147,7 +147,7 @@
.features { .features {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 2.5mm 6mm; gap: 1.8mm 6mm;
flex-shrink: 0; flex-shrink: 0;
position: relative; position: relative;
z-index: 1; z-index: 1;
@@ -158,7 +158,7 @@
gap: 2.5mm; gap: 2.5mm;
align-items: flex-start; align-items: flex-start;
font-size: 10.5pt; font-size: 10.5pt;
line-height: 1.4; line-height: 1.28;
color: #e2e8f0; color: #e2e8f0;
} }
@@ -329,6 +329,7 @@
<div class="feature"><span class="feature-icon"></span><span>Logbuch mit Freunden teilen</span></div> <div class="feature"><span class="feature-icon"></span><span>Logbuch mit Freunden teilen</span></div>
<div class="feature"><span class="feature-icon"></span><span>Beliebig viele Schiffe und Logbücher</span></div> <div class="feature"><span class="feature-icon"></span><span>Beliebig viele Schiffe und Logbücher</span></div>
<div class="feature"><span class="feature-icon"></span><span class="lang-list"><span class="lang-item"><svg class="feature-flag" viewBox="0 0 5 3" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect width="5" height="1" fill="#000"/><rect y="1" width="5" height="1" fill="#D00"/><rect y="2" width="5" height="1" fill="#FFCE00"/></svg>Deutsch</span><span class="lang-sep">&amp;</span><span class="lang-item"><svg class="feature-flag" viewBox="0 0 60 30" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><clipPath id="gb-a"><path d="M0 0v30h60V0z"/></clipPath><clipPath id="gb-b"><path d="M30 15h30v15zv15H0z"/></clipPath><g clip-path="url(#gb-a)"><path d="M0 0v30h60V0z" fill="#012169"/><path d="M0 0l60 30m0-30L0 30" stroke="#fff" stroke-width="6"/><path d="M0 0l60 30m0-30L0 30" clip-path="url(#gb-b)" stroke="#C8102E" stroke-width="4"/><path d="M30 0v30M0 15h60" stroke="#fff" stroke-width="10"/><path d="M30 0v30M0 15h60" stroke="#C8102E" stroke-width="6"/></g></svg>Englisch</span></span></div> <div class="feature"><span class="feature-icon"></span><span class="lang-list"><span class="lang-item"><svg class="feature-flag" viewBox="0 0 5 3" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><rect width="5" height="1" fill="#000"/><rect y="1" width="5" height="1" fill="#D00"/><rect y="2" width="5" height="1" fill="#FFCE00"/></svg>Deutsch</span><span class="lang-sep">&amp;</span><span class="lang-item"><svg class="feature-flag" viewBox="0 0 60 30" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><clipPath id="gb-a"><path d="M0 0v30h60V0z"/></clipPath><clipPath id="gb-b"><path d="M30 15h30v15zv15H0z"/></clipPath><g clip-path="url(#gb-a)"><path d="M0 0v30h60V0z" fill="#012169"/><path d="M0 0l60 30m0-30L0 30" stroke="#fff" stroke-width="6"/><path d="M0 0l60 30m0-30L0 30" clip-path="url(#gb-b)" stroke="#C8102E" stroke-width="4"/><path d="M30 0v30M0 15h60" stroke="#fff" stroke-width="10"/><path d="M30 0v30M0 15h60" stroke="#C8102E" stroke-width="6"/></g></svg>Englisch</span></span></div>
<div class="feature"><span class="feature-icon"></span><span>3 Themes, jeweils mit heller und dunkler Variante</span></div>
<div class="feature"><span class="feature-icon"></span><span>Crafted in Kiel.Sailing.City.</span></div> <div class="feature"><span class="feature-icon"></span><span>Crafted in Kiel.Sailing.City.</span></div>
</section> </section>
Binary file not shown.