Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 484ed66b7b | |||
| 49d77f08a2 | |||
| 951b5b3f1c | |||
| abb708c3d0 | |||
| cc87b0f8e6 | |||
| 58984594b0 | |||
| 61675e1085 | |||
| 2082218f78 | |||
| 5882edcbdf | |||
| b7a47a1d90 | |||
| 48c408302f | |||
| 2b5c5d4a36 | |||
| 7cf04b3357 | |||
| bbd4281dcb | |||
| d2833f7664 | |||
| 2a14080b5b | |||
| 2457fa41e3 | |||
| 87b0fa7bde | |||
| d90f292a21 | |||
| 9e42f828a0 | |||
| 4197e77b1e | |||
| 1373c11de8 | |||
| 0bae3b29dc | |||
| 73e86d28b3 | |||
| ad4721e694 | |||
| 8037b3b63e | |||
| c4cd566da0 | |||
| 3a267905b0 | |||
| c856c2e903 | |||
| b3256d1685 | |||
| 23fc940324 | |||
| 25e1bdded3 | |||
| 6a61c9e06c | |||
| d3683ad6aa | |||
| ef5891ba3f | |||
| d25095bab3 | |||
| 0d16782001 | |||
| b7e2d470a9 | |||
| 520ba766a3 | |||
| c215cd8b15 | |||
| 27c780d2b8 | |||
| aa52948ddc | |||
| 49b4e7b9c3 | |||
| 2d64987ada | |||
| 87973eaa4a | |||
| 93e26b7807 | |||
| 814eeadd1f | |||
| d9cbcd8e43 | |||
| 282e7ba8ba | |||
| b86e5a15d6 | |||
| eac86ec655 | |||
| a6331bea1a | |||
| ae89b131a1 | |||
| 3fdea31c4a | |||
| 04d114c315 | |||
| 3fa66f044c | |||
| a84c611402 | |||
| f12b9b2a1a | |||
| 34914b4f19 | |||
| d9fa8c0edf | |||
| adf02acd45 | |||
| 3992db9d61 | |||
| 51f6a1b291 | |||
| 0b07d8b3d3 | |||
| a07e033e62 | |||
| bbe63dfb47 | |||
| 57f63ad486 | |||
| 728c40f936 | |||
| 72cbad8d5e | |||
| 15f2172a38 |
@@ -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) |
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -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>
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ server {
|
|||||||
client_max_body_size 50M;
|
client_max_body_size 50M;
|
||||||
|
|
||||||
# Service worker and app shell must revalidate so PWA updates are detected
|
# Service worker and app shell must revalidate so PWA updates are detected
|
||||||
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest)$ {
|
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
}
|
||||||
|
})()
|
||||||
+425
-10
@@ -8,6 +8,18 @@ body {
|
|||||||
color: var(--app-text);
|
color: var(--app-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||||
|
color: var(--app-input-text);
|
||||||
|
background: var(--app-icon-btn-bg);
|
||||||
|
border: 1px solid var(--app-icon-btn-border);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 135%;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
#root:has(.auth-screen) {
|
#root:has(.auth-screen) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
@@ -1046,6 +1058,7 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
.profile-dl-row dd {
|
.profile-dl-row dd {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
color: var(--app-text);
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
justify-self: start;
|
justify-self: start;
|
||||||
@@ -1059,8 +1072,6 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
|
|
||||||
.profile-user-id code {
|
.profile-user-id code {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
background: rgba(148, 163, 184, 0.08);
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
@@ -1097,6 +1108,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;
|
||||||
@@ -1126,8 +1138,8 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: rgba(148, 163, 184, 0.06);
|
background: var(--app-icon-btn-bg);
|
||||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
border: 1px solid var(--app-icon-btn-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-passkey-main {
|
.profile-passkey-main {
|
||||||
@@ -1240,6 +1252,7 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
display: block;
|
display: block;
|
||||||
font-family: ui-monospace, monospace;
|
font-family: ui-monospace, monospace;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
color: var(--app-input-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-passkey-transports {
|
.profile-passkey-transports {
|
||||||
@@ -1358,6 +1371,140 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
gap: 20px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-list-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-filter-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-sort-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-sort-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-sort-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-sort-group {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-sort-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--app-border-subtle);
|
||||||
|
background: var(--app-surface-alt);
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s, background-color 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-sort-btn:hover {
|
||||||
|
border-color: var(--app-border);
|
||||||
|
color: var(--app-text-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-sort-btn.is-active {
|
||||||
|
border-color: var(--app-accent-border);
|
||||||
|
background: var(--app-accent-bg);
|
||||||
|
color: var(--app-accent-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-sort-btn:focus-visible {
|
||||||
|
outline: 2px solid var(--app-accent-focus-ring);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-filter-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-filter-input-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-filter-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 14px;
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
pointer-events: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-filter-input {
|
||||||
|
width: 100%;
|
||||||
|
padding-left: 42px;
|
||||||
|
padding-right: 42px;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-filter-input::-webkit-search-cancel-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-filter-clear {
|
||||||
|
position: absolute;
|
||||||
|
right: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s, background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-filter-clear:hover {
|
||||||
|
color: var(--app-text-heading);
|
||||||
|
background: var(--app-accent-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-filter-clear:focus-visible {
|
||||||
|
outline: 2px solid var(--app-accent-focus-ring);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-filter-meta {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--app-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.section-title-bar {
|
.section-title-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -2171,6 +2318,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;
|
||||||
@@ -2303,6 +2456,37 @@ html.scheme-dark .themed-select-option.is-selected {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-sort-bar {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-sort-label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 4.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-sort-row {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-sort-group {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-sort-btn {
|
||||||
|
flex: 1;
|
||||||
|
width: auto;
|
||||||
|
min-width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
.logbooks-grid {
|
.logbooks-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
@@ -2433,6 +2617,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 +2906,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 +2960,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 +2988,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2985,6 +3233,98 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tank-liter-input .tank-liter-slider {
|
||||||
|
--tank-slider-track-h: 10px;
|
||||||
|
--tank-slider-thumb: 26px;
|
||||||
|
width: 100%;
|
||||||
|
height: var(--tank-slider-thumb);
|
||||||
|
margin: 10px 0 6px;
|
||||||
|
padding: 0;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
accent-color: #4ade80;
|
||||||
|
cursor: pointer;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tank-liter-input .tank-liter-slider::-webkit-slider-runnable-track {
|
||||||
|
height: var(--tank-slider-track-h);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(148, 163, 184, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tank-liter-input .tank-liter-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: var(--tank-slider-thumb);
|
||||||
|
height: var(--tank-slider-thumb);
|
||||||
|
margin-top: calc((var(--tank-slider-track-h) - var(--tank-slider-thumb)) / 2);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #4ade80;
|
||||||
|
border: 2px solid rgba(15, 23, 42, 0.85);
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tank-liter-input .tank-liter-slider::-moz-range-track {
|
||||||
|
height: var(--tank-slider-track-h);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(148, 163, 184, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tank-liter-input .tank-liter-slider::-moz-range-thumb {
|
||||||
|
width: var(--tank-slider-thumb);
|
||||||
|
height: var(--tank-slider-thumb);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #4ade80;
|
||||||
|
border: 2px solid rgba(15, 23, 42, 0.85);
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tank-liter-input .tank-liter-slider:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tank-liter-input .tank-liter-slider-hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.tank-liter-input .tank-liter-slider {
|
||||||
|
--tank-slider-track-h: 12px;
|
||||||
|
--tank-slider-thumb: 32px;
|
||||||
|
margin: 12px 0 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vessel-tanks-section {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vessel-tanks-section h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vessel-tanks-help {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vessel-tanks-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
/* GPS Track Upload & Map Styling */
|
/* GPS Track Upload & Map Styling */
|
||||||
.track-upload-zone {
|
.track-upload-zone {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -3457,6 +3797,59 @@ html.theme-cupertino .events-scroll-container {
|
|||||||
.stats-kpi-value {
|
.stats-kpi-value {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-stats-section.form-card {
|
||||||
|
padding: 20px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-stats-section .form-header {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-stats-section .form-header h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-stats-section .stats-subtitle {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-stats-kpi-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-stats-kpi-grid .stats-kpi-card {
|
||||||
|
padding: 10px;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-stats-kpi-grid .stats-kpi-icon {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-stats-kpi-grid .stats-kpi-icon svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-stats-kpi-grid .stats-kpi-label {
|
||||||
|
font-size: 11px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-stats-kpi-grid .stats-kpi-value {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-stats-kpi-grid .stats-kpi-unit {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.signature-grid {
|
.signature-grid {
|
||||||
@@ -4062,6 +4455,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;
|
||||||
@@ -4173,10 +4586,12 @@ body.app-tour-active .app-tour-target-active {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.app-tour-tooltip:not(.centered) {
|
.app-tour-tooltip:not(.centered) {
|
||||||
left: max(16px, env(safe-area-inset-left, 0px));
|
left: 50%;
|
||||||
right: max(16px, env(safe-area-inset-right, 0px));
|
transform: translateX(-50%);
|
||||||
width: auto;
|
}
|
||||||
max-width: none;
|
|
||||||
|
.app-tour-tooltip:not(.centered).app-tour-tooltip--anchored {
|
||||||
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-tour-tooltip.centered {
|
.app-tour-tooltip.centered {
|
||||||
|
|||||||
+93
-29
@@ -1,5 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import './App.css'
|
|
||||||
import { DialogProvider } from './components/ModalDialog.tsx'
|
import { DialogProvider } from './components/ModalDialog.tsx'
|
||||||
import AuthOnboarding from './components/AuthOnboarding.tsx'
|
import AuthOnboarding from './components/AuthOnboarding.tsx'
|
||||||
import UserProfilePage from './components/UserProfilePage.tsx'
|
import UserProfilePage from './components/UserProfilePage.tsx'
|
||||||
@@ -16,10 +15,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'
|
||||||
@@ -29,6 +28,7 @@ import {
|
|||||||
resolveColorScheme,
|
resolveColorScheme,
|
||||||
subscribeToSystemColorScheme
|
subscribeToSystemColorScheme
|
||||||
} from './services/appearance.js'
|
} from './services/appearance.js'
|
||||||
|
import { syncAppearancePrefs } from './services/appearancePrefs.js'
|
||||||
import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks, subscribeToSyncState } from './services/sync.js'
|
import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks, subscribeToSyncState } from './services/sync.js'
|
||||||
import ReadOnlyViewer from './components/ReadOnlyViewer.tsx'
|
import ReadOnlyViewer from './components/ReadOnlyViewer.tsx'
|
||||||
import DemoViewer from './components/DemoViewer.tsx'
|
import DemoViewer from './components/DemoViewer.tsx'
|
||||||
@@ -46,7 +46,7 @@ import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
|
|||||||
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
|
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
import {
|
||||||
getStoredDemoFirstEntryId,
|
resolveTourLogbookContext,
|
||||||
seedDemoLogbookIfNeeded
|
seedDemoLogbookIfNeeded
|
||||||
} from './services/demoLogbook.js'
|
} from './services/demoLogbook.js'
|
||||||
import { fetchLogbooks, parseCollaborationRole } from './services/logbook.js'
|
import { fetchLogbooks, parseCollaborationRole } from './services/logbook.js'
|
||||||
@@ -57,7 +57,7 @@ const PENDING_PUSH_LOGBOOK_KEY = 'pending_push_logbook_id'
|
|||||||
function App() {
|
function App() {
|
||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
const { confirmLeave } = useUnsavedChangesContext()
|
const { confirmLeave } = useUnsavedChangesContext()
|
||||||
const { registerNavigation, requestStartAfterLogin, isActive, currentStepId } = useAppTour()
|
const { registerNavigation, registerDemoTourContext, requestStartAfterLogin, isActive, currentStepId } = useAppTour()
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||||
const [activeLogbookId, setActiveLogbookId] = useState<string | null>(null)
|
const [activeLogbookId, setActiveLogbookId] = useState<string | null>(null)
|
||||||
const [activeLogbookTitle, setActiveLogbookTitle] = useState<string | null>(null)
|
const [activeLogbookTitle, setActiveLogbookTitle] = useState<string | null>(null)
|
||||||
@@ -69,6 +69,12 @@ function App() {
|
|||||||
const [isSyncing, setIsSyncing] = useState(false)
|
const [isSyncing, setIsSyncing] = useState(false)
|
||||||
const [isAcceptingInvite, setIsAcceptingInvite] = useState(false)
|
const [isAcceptingInvite, setIsAcceptingInvite] = useState(false)
|
||||||
const [showUserProfile, setShowUserProfile] = useState(false)
|
const [showUserProfile, setShowUserProfile] = useState(false)
|
||||||
|
const tourLogbookRef = useRef<{ id: string; title: string } | null>(null)
|
||||||
|
const activeLogbookRef = useRef<{ id: string | null; title: string | null }>({
|
||||||
|
id: activeLogbookId,
|
||||||
|
title: activeLogbookTitle
|
||||||
|
})
|
||||||
|
activeLogbookRef.current = { id: activeLogbookId, title: activeLogbookTitle }
|
||||||
|
|
||||||
// Viewer mode for read-only shared links
|
// Viewer mode for read-only shared links
|
||||||
const [isViewerMode, setIsViewerMode] = useState(false)
|
const [isViewerMode, setIsViewerMode] = useState(false)
|
||||||
@@ -145,6 +151,13 @@ function App() {
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) return
|
||||||
|
const userId = localStorage.getItem('active_userid')
|
||||||
|
if (!userId) return
|
||||||
|
void syncAppearancePrefs(userId)
|
||||||
|
}, [isAuthenticated])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleOnline = () => {
|
const handleOnline = () => {
|
||||||
setOnline(true)
|
setOnline(true)
|
||||||
@@ -226,6 +239,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 +282,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 +295,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)
|
||||||
@@ -308,28 +322,66 @@ function App() {
|
|||||||
setIsAcceptingInvite(false)
|
setIsAcceptingInvite(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
const selectLogbook = useCallback((id: string, title: string) => {
|
||||||
registerNavigation({
|
|
||||||
setActiveTab,
|
|
||||||
setSelectedEntryId: setTourSelectedEntryId,
|
|
||||||
setFeedbackOpen: setTourFeedbackOpen
|
|
||||||
})
|
|
||||||
}, [registerNavigation])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isAuthenticated && activeLogbookId) {
|
|
||||||
setDemoHighlightEntryId(getStoredDemoFirstEntryId())
|
|
||||||
}
|
|
||||||
}, [isAuthenticated, activeLogbookId])
|
|
||||||
|
|
||||||
const selectLogbook = (id: string, title: string) => {
|
|
||||||
setActiveLogbookId(id)
|
setActiveLogbookId(id)
|
||||||
setActiveLogbookTitle(title)
|
setActiveLogbookTitle(title)
|
||||||
setActiveTab('logs')
|
setActiveTab('logs')
|
||||||
setTourSelectedEntryId(null)
|
setTourSelectedEntryId(null)
|
||||||
localStorage.setItem('active_logbook_id', id)
|
localStorage.setItem('active_logbook_id', id)
|
||||||
localStorage.setItem('active_logbook_title', title)
|
localStorage.setItem('active_logbook_title', title)
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
|
const ensureTourLogbookOpen = useCallback(async () => {
|
||||||
|
const ctx = await resolveTourLogbookContext(tourLogbookRef.current?.id)
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
if (activeLogbookRef.current.id !== ctx.logbookId) {
|
||||||
|
selectLogbook(ctx.logbookId, ctx.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.firstEntryId) {
|
||||||
|
setDemoHighlightEntryId(ctx.firstEntryId)
|
||||||
|
registerDemoTourContext({ firstEntryId: ctx.firstEntryId })
|
||||||
|
}
|
||||||
|
}, [registerDemoTourContext, selectLogbook])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
registerNavigation({
|
||||||
|
setActiveTab,
|
||||||
|
setSelectedEntryId: setTourSelectedEntryId,
|
||||||
|
setFeedbackOpen: setTourFeedbackOpen,
|
||||||
|
setProfileOpen: setShowUserProfile,
|
||||||
|
ensureLogbookForTour: ensureTourLogbookOpen,
|
||||||
|
setLogbookActive: (active) => {
|
||||||
|
if (active) {
|
||||||
|
void ensureTourLogbookOpen()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id, title } = activeLogbookRef.current
|
||||||
|
if (id && title) {
|
||||||
|
tourLogbookRef.current = { id, title }
|
||||||
|
}
|
||||||
|
setActiveLogbookId(null)
|
||||||
|
setActiveLogbookTitle(null)
|
||||||
|
setTourSelectedEntryId(null)
|
||||||
|
localStorage.removeItem('active_logbook_id')
|
||||||
|
localStorage.removeItem('active_logbook_title')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [ensureTourLogbookOpen, registerNavigation])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated || !activeLogbookId) return
|
||||||
|
void (async () => {
|
||||||
|
const ctx = await resolveTourLogbookContext()
|
||||||
|
if (!ctx || ctx.logbookId !== activeLogbookId) return
|
||||||
|
if (ctx.firstEntryId) {
|
||||||
|
setDemoHighlightEntryId(ctx.firstEntryId)
|
||||||
|
registerDemoTourContext({ firstEntryId: ctx.firstEntryId })
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}, [isAuthenticated, activeLogbookId, registerDemoTourContext])
|
||||||
|
|
||||||
const openLogbookById = useCallback(
|
const openLogbookById = useCallback(
|
||||||
async (logbookId: string) => {
|
async (logbookId: string) => {
|
||||||
@@ -345,7 +397,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
selectLogbook(logbookId, `${logbookId.slice(0, 8)}…`)
|
selectLogbook(logbookId, `${logbookId.slice(0, 8)}…`)
|
||||||
},
|
},
|
||||||
[]
|
[selectLogbook]
|
||||||
)
|
)
|
||||||
|
|
||||||
const consumePendingPushLogbook = useCallback(() => {
|
const consumePendingPushLogbook = useCallback(() => {
|
||||||
@@ -397,8 +449,20 @@ function App() {
|
|||||||
const savedLogbookId = localStorage.getItem('active_logbook_id')
|
const savedLogbookId = localStorage.getItem('active_logbook_id')
|
||||||
const savedLogbookTitle = localStorage.getItem('active_logbook_title')
|
const savedLogbookTitle = localStorage.getItem('active_logbook_title')
|
||||||
if (savedLogbookId && savedLogbookTitle) {
|
if (savedLogbookId && savedLogbookTitle) {
|
||||||
setActiveLogbookId(savedLogbookId)
|
try {
|
||||||
setActiveLogbookTitle(savedLogbookTitle)
|
const books = await fetchLogbooks()
|
||||||
|
const match = books.find((b) => b.id === savedLogbookId)
|
||||||
|
if (match) {
|
||||||
|
setActiveLogbookId(match.id)
|
||||||
|
setActiveLogbookTitle(match.title)
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('active_logbook_id')
|
||||||
|
localStorage.removeItem('active_logbook_title')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setActiveLogbookId(savedLogbookId)
|
||||||
|
setActiveLogbookTitle(savedLogbookTitle)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
consumePendingPushLogbook()
|
consumePendingPushLogbook()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { X, ChevronLeft, ChevronRight } from 'lucide-react'
|
|||||||
import {
|
import {
|
||||||
getTourStepCopy,
|
getTourStepCopy,
|
||||||
getTourTargetSelector,
|
getTourTargetSelector,
|
||||||
|
getTourTargetRetryDelay,
|
||||||
isCenteredTourStep,
|
isCenteredTourStep,
|
||||||
useAppTour
|
useAppTour
|
||||||
} from '../context/AppTourContext.tsx'
|
} from '../context/AppTourContext.tsx'
|
||||||
@@ -17,6 +18,20 @@ interface SpotlightRect {
|
|||||||
|
|
||||||
const TOOLTIP_EDGE_MARGIN = 16
|
const TOOLTIP_EDGE_MARGIN = 16
|
||||||
const TOOLTIP_ESTIMATED_HEIGHT = 240
|
const TOOLTIP_ESTIMATED_HEIGHT = 240
|
||||||
|
const TOOLTIP_WIDTH = 420
|
||||||
|
const TARGET_VIEWPORT_MARGIN = 24
|
||||||
|
|
||||||
|
function clampTooltipTop(preferred: number): number {
|
||||||
|
const maxTop = window.innerHeight - TOOLTIP_EDGE_MARGIN - TOOLTIP_ESTIMATED_HEIGHT
|
||||||
|
return Math.max(TOOLTIP_EDGE_MARGIN, Math.min(preferred, maxTop))
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeTooltipLeft(spotlight: SpotlightRect): number {
|
||||||
|
const tooltipWidth = Math.min(TOOLTIP_WIDTH, window.innerWidth - TOOLTIP_EDGE_MARGIN * 2)
|
||||||
|
const ideal = spotlight.left + spotlight.width / 2 - tooltipWidth / 2
|
||||||
|
const maxLeft = window.innerWidth - TOOLTIP_EDGE_MARGIN - tooltipWidth
|
||||||
|
return Math.max(TOOLTIP_EDGE_MARGIN, Math.min(ideal, maxLeft))
|
||||||
|
}
|
||||||
|
|
||||||
function buildCutoutClipPath(rect: SpotlightRect): string {
|
function buildCutoutClipPath(rect: SpotlightRect): string {
|
||||||
const right = rect.left + rect.width
|
const right = rect.left + rect.width
|
||||||
@@ -28,20 +43,36 @@ function computeTooltipTop(spotlight: SpotlightRect): number {
|
|||||||
const viewportBottom = window.innerHeight - TOOLTIP_EDGE_MARGIN
|
const viewportBottom = window.innerHeight - TOOLTIP_EDGE_MARGIN
|
||||||
const below = spotlight.top + spotlight.height + 12
|
const below = spotlight.top + spotlight.height + 12
|
||||||
if (below + TOOLTIP_ESTIMATED_HEIGHT <= viewportBottom) {
|
if (below + TOOLTIP_ESTIMATED_HEIGHT <= viewportBottom) {
|
||||||
return below
|
return clampTooltipTop(below)
|
||||||
}
|
}
|
||||||
|
|
||||||
const above = spotlight.top - 12 - TOOLTIP_ESTIMATED_HEIGHT
|
const above = spotlight.top - 12 - TOOLTIP_ESTIMATED_HEIGHT
|
||||||
if (above >= TOOLTIP_EDGE_MARGIN) {
|
if (above >= TOOLTIP_EDGE_MARGIN) {
|
||||||
return above
|
return clampTooltipTop(above)
|
||||||
}
|
}
|
||||||
|
|
||||||
return Math.max(
|
return clampTooltipTop(below)
|
||||||
TOOLTIP_EDGE_MARGIN,
|
}
|
||||||
Math.min(below, viewportBottom - TOOLTIP_ESTIMATED_HEIGHT)
|
|
||||||
|
function isTargetVisibleInViewport(rect: DOMRect): boolean {
|
||||||
|
return (
|
||||||
|
rect.top >= TARGET_VIEWPORT_MARGIN &&
|
||||||
|
rect.bottom <= window.innerHeight - TARGET_VIEWPORT_MARGIN
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function measureSpotlight(el: Element): SpotlightRect | null {
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
if (rect.width <= 0 || rect.height <= 0) return null
|
||||||
|
const padding = 8
|
||||||
|
return {
|
||||||
|
top: Math.max(8, rect.top - padding),
|
||||||
|
left: Math.max(8, rect.left - padding),
|
||||||
|
width: rect.width + padding * 2,
|
||||||
|
height: rect.height + padding * 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function AppTourOverlay() {
|
export default function AppTourOverlay() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const {
|
const {
|
||||||
@@ -50,6 +81,7 @@ export default function AppTourOverlay() {
|
|||||||
currentStepId,
|
currentStepId,
|
||||||
currentStepIndex,
|
currentStepIndex,
|
||||||
totalSteps,
|
totalSteps,
|
||||||
|
layoutTick,
|
||||||
nextStep,
|
nextStep,
|
||||||
prevStep,
|
prevStep,
|
||||||
skipTour
|
skipTour
|
||||||
@@ -65,7 +97,10 @@ export default function AppTourOverlay() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
const updateSpotlight = () => {
|
const updateSpotlight = () => {
|
||||||
|
if (cancelled) return
|
||||||
const selector = getTourTargetSelector(currentStepId)
|
const selector = getTourTargetSelector(currentStepId)
|
||||||
if (!selector) {
|
if (!selector) {
|
||||||
setSpotlight(null)
|
setSpotlight(null)
|
||||||
@@ -76,27 +111,38 @@ export default function AppTourOverlay() {
|
|||||||
setSpotlight(null)
|
setSpotlight(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const rect = el.getBoundingClientRect()
|
const rect = el.getBoundingClientRect()
|
||||||
const padding = 8
|
if (!isTargetVisibleInViewport(rect)) {
|
||||||
setSpotlight({
|
el.scrollIntoView({ behavior: 'instant', block: 'center', inline: 'nearest' })
|
||||||
top: Math.max(8, rect.top - padding),
|
window.requestAnimationFrame(() => {
|
||||||
left: Math.max(8, rect.left - padding),
|
if (cancelled) return
|
||||||
width: rect.width + padding * 2,
|
const next = measureSpotlight(el)
|
||||||
height: rect.height + padding * 2
|
setSpotlight(next)
|
||||||
})
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSpotlight(measureSpotlight(el))
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSpotlight()
|
updateSpotlight()
|
||||||
window.addEventListener('resize', updateSpotlight)
|
window.addEventListener('resize', updateSpotlight)
|
||||||
window.addEventListener('scroll', updateSpotlight, true)
|
window.addEventListener('scroll', updateSpotlight, true)
|
||||||
const timer = window.setTimeout(updateSpotlight, 120)
|
|
||||||
|
const retryDelays =
|
||||||
|
currentStepId === 'entry_track'
|
||||||
|
? [400, 700, 1100, 1600]
|
||||||
|
: [getTourTargetRetryDelay(currentStepId), 120, 280, 480]
|
||||||
|
const timers = retryDelays.map((delay) => window.setTimeout(updateSpotlight, delay))
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.clearTimeout(timer)
|
cancelled = true
|
||||||
|
for (const timer of timers) window.clearTimeout(timer)
|
||||||
window.removeEventListener('resize', updateSpotlight)
|
window.removeEventListener('resize', updateSpotlight)
|
||||||
window.removeEventListener('scroll', updateSpotlight, true)
|
window.removeEventListener('scroll', updateSpotlight, true)
|
||||||
}
|
}
|
||||||
}, [currentStepId, isActive])
|
}, [currentStepId, isActive, layoutTick])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isActive) return
|
if (!isActive) return
|
||||||
@@ -132,9 +178,17 @@ export default function AppTourOverlay() {
|
|||||||
const tooltipStyle = centered
|
const tooltipStyle = centered
|
||||||
? undefined
|
? undefined
|
||||||
: spotlight
|
: spotlight
|
||||||
? { top: computeTooltipTop(spotlight) }
|
? { top: computeTooltipTop(spotlight), left: computeTooltipLeft(spotlight) }
|
||||||
: { top: '20%' }
|
: { top: '20%' }
|
||||||
|
|
||||||
|
const tooltipClassName = [
|
||||||
|
'app-tour-tooltip',
|
||||||
|
centered ? 'centered' : '',
|
||||||
|
!centered && spotlight ? 'app-tour-tooltip--anchored' : ''
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
|
||||||
const backdropStyle = spotlight && !centered
|
const backdropStyle = spotlight && !centered
|
||||||
? { clipPath: buildCutoutClipPath(spotlight) }
|
? { clipPath: buildCutoutClipPath(spotlight) }
|
||||||
: undefined
|
: undefined
|
||||||
@@ -159,7 +213,7 @@ export default function AppTourOverlay() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={`app-tour-tooltip${centered ? ' centered' : ''}`} style={tooltipStyle}>
|
<div className={tooltipClassName} style={tooltipStyle}>
|
||||||
<button type="button" className="app-tour-close" onClick={skipTour} aria-label={t('tour.skip')}>
|
<button type="button" className="app-tour-close" onClick={skipTour} aria-label={t('tour.skip')}>
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -377,16 +379,37 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
|||||||
{t('auth.recovery_fallback_warning')}
|
{t('auth.recovery_fallback_warning')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form onSubmit={handleRecoverySubmit} className="auth-form">
|
<form onSubmit={handleRecoverySubmit} className="auth-form" autoComplete="on">
|
||||||
<textarea
|
{(username.trim() || encryptedPayloads?.username) && (
|
||||||
className="input-textarea"
|
<input
|
||||||
placeholder={t('auth.recovery_placeholder')}
|
type="text"
|
||||||
value={recoveryInput}
|
name="username"
|
||||||
onChange={(e) => setRecoveryInput(e.target.value)}
|
autoComplete="username"
|
||||||
disabled={loading}
|
value={username.trim() || encryptedPayloads?.username || ''}
|
||||||
rows={3}
|
readOnly
|
||||||
required
|
tabIndex={-1}
|
||||||
/>
|
aria-hidden="true"
|
||||||
|
style={{ position: 'absolute', width: 0, height: 0, opacity: 0, pointerEvents: 'none' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="input-group">
|
||||||
|
<label htmlFor="recovery-key" className="input-label" style={{ display: 'block', marginBottom: '8px', color: '#94a3b8' }}>
|
||||||
|
{t('auth.enter_recovery')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="recovery-key"
|
||||||
|
name="recovery-key"
|
||||||
|
type="password"
|
||||||
|
className="input-text"
|
||||||
|
placeholder={t('auth.recovery_placeholder')}
|
||||||
|
value={recoveryInput}
|
||||||
|
onChange={(e) => setRecoveryInput(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && <div className="auth-error">{error}</div>}
|
{error && <div className="auth-error">{error}</div>}
|
||||||
|
|
||||||
@@ -410,6 +433,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 +594,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)} />
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
|||||||
registerNavigation({
|
registerNavigation({
|
||||||
setActiveTab,
|
setActiveTab,
|
||||||
setSelectedEntryId: setTourSelectedEntryId,
|
setSelectedEntryId: setTourSelectedEntryId,
|
||||||
setFeedbackOpen: () => {}
|
setFeedbackOpen: () => {},
|
||||||
|
setLogbookActive: () => {},
|
||||||
|
setProfileOpen: () => {}
|
||||||
})
|
})
|
||||||
registerDemoTourContext({ firstEntryId: fixture.firstEntryId })
|
registerDemoTourContext({ firstEntryId: fixture.firstEntryId })
|
||||||
|
|
||||||
|
|||||||
@@ -344,15 +344,36 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
|||||||
<h2>{t('auth.enter_recovery')}</h2>
|
<h2>{t('auth.enter_recovery')}</h2>
|
||||||
</div>
|
</div>
|
||||||
<p className="recovery-warning">{t('auth.recovery_fallback_warning')}</p>
|
<p className="recovery-warning">{t('auth.recovery_fallback_warning')}</p>
|
||||||
<form onSubmit={handleRecoverySubmit}>
|
<form onSubmit={handleRecoverySubmit} autoComplete="on">
|
||||||
<textarea
|
{(username.trim() || encryptedPayloads?.username) && (
|
||||||
className="input-text"
|
<input
|
||||||
placeholder={t('auth.recovery_placeholder')}
|
type="text"
|
||||||
value={recoveryInput}
|
name="username"
|
||||||
onChange={(e) => setRecoveryInput(e.target.value)}
|
autoComplete="username"
|
||||||
rows={3}
|
value={username.trim() || encryptedPayloads?.username || ''}
|
||||||
required
|
readOnly
|
||||||
/>
|
tabIndex={-1}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ position: 'absolute', width: 0, height: 0, opacity: 0, pointerEvents: 'none' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="input-group">
|
||||||
|
<label htmlFor="invitation-recovery-key" className="input-label" style={{ display: 'block', marginBottom: '8px', color: '#94a3b8' }}>
|
||||||
|
{t('auth.enter_recovery')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="invitation-recovery-key"
|
||||||
|
name="recovery-key"
|
||||||
|
type="password"
|
||||||
|
className="input-text"
|
||||||
|
placeholder={t('auth.recovery_placeholder')}
|
||||||
|
value={recoveryInput}
|
||||||
|
onChange={(e) => setRecoveryInput(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="auth-actions mt-4">
|
<div className="auth-actions mt-4">
|
||||||
<button type="button" className="btn secondary" onClick={() => setShowRecoveryFallback(false)}>
|
<button type="button" className="btn secondary" onClick={() => setShowRecoveryFallback(false)}>
|
||||||
{t('auth.back')}
|
{t('auth.back')}
|
||||||
|
|||||||
@@ -241,14 +241,15 @@ export default function LogEntriesList({
|
|||||||
|
|
||||||
decryptedEntries.sort(compareTravelDaysChronological)
|
decryptedEntries.sort(compareTravelDaysChronological)
|
||||||
const previousEntry = decryptedEntries.at(-1) ?? null
|
const previousEntry = decryptedEntries.at(-1) ?? null
|
||||||
let { freshwater, fuel, departure } = carryOverFromPreviousDay(previousEntry)
|
let { freshwater, fuel, greywaterLevel, departure } = carryOverFromPreviousDay(previousEntry)
|
||||||
|
|
||||||
if (previousEntry && hasCarryOverFromPreviousDay({ freshwater, fuel, departure })) {
|
if (previousEntry && hasCarryOverFromPreviousDay({ freshwater, fuel, greywaterLevel, departure })) {
|
||||||
const confirmed = await showConfirm(
|
const confirmed = await showConfirm(
|
||||||
t('logs.carry_over_tanks_confirm', {
|
t('logs.carry_over_tanks_confirm', {
|
||||||
departure: departure || '—',
|
departure: departure || '—',
|
||||||
fw: formatTankLiters(freshwater.morning),
|
fw: formatTankLiters(freshwater.morning),
|
||||||
fuel: formatTankLiters(fuel.morning)
|
fuel: formatTankLiters(fuel.morning),
|
||||||
|
greywater: formatTankLiters(greywaterLevel)
|
||||||
}),
|
}),
|
||||||
t('logs.carry_over_tanks_title'),
|
t('logs.carry_over_tanks_title'),
|
||||||
t('logs.carry_over_tanks_yes'),
|
t('logs.carry_over_tanks_yes'),
|
||||||
@@ -257,6 +258,7 @@ export default function LogEntriesList({
|
|||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
freshwater = emptyTankLevels()
|
freshwater = emptyTankLevels()
|
||||||
fuel = emptyTankLevels()
|
fuel = emptyTankLevels()
|
||||||
|
greywaterLevel = 0
|
||||||
departure = ''
|
departure = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -274,6 +276,7 @@ export default function LogEntriesList({
|
|||||||
destination: '',
|
destination: '',
|
||||||
freshwater,
|
freshwater,
|
||||||
fuel,
|
fuel,
|
||||||
|
...(greywaterLevel > 0 ? { greywater: { level: greywaterLevel } } : {}),
|
||||||
signSkipper: '',
|
signSkipper: '',
|
||||||
signCrew: '',
|
signCrew: '',
|
||||||
events: []
|
events: []
|
||||||
@@ -365,6 +368,11 @@ export default function LogEntriesList({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tourFirstEntryId =
|
||||||
|
highlightEntryId && entries.some((e) => e.id === highlightEntryId)
|
||||||
|
? highlightEntryId
|
||||||
|
: entries[0]?.id ?? null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="form-card">
|
<div className="form-card">
|
||||||
<div className="section-title-bar mb-6">
|
<div className="section-title-bar mb-6">
|
||||||
@@ -402,7 +410,7 @@ export default function LogEntriesList({
|
|||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className="logbook-card glass"
|
className="logbook-card glass"
|
||||||
data-tour={highlightEntryId === item.id ? 'entry-first' : undefined}
|
data-tour={tourFirstEntryId === item.id ? 'entry-first' : undefined}
|
||||||
onClick={() => setSelectedEntryId(item.id)}
|
onClick={() => setSelectedEntryId(item.id)}
|
||||||
>
|
>
|
||||||
<div className="card-icon">
|
<div className="card-icon">
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
hasAnySignature
|
hasAnySignature
|
||||||
} from '../utils/signatures.js'
|
} from '../utils/signatures.js'
|
||||||
import type { SignatureValue } from '../types/signatures.js'
|
import type { SignatureValue } from '../types/signatures.js'
|
||||||
import { buildLogEntryPayload, sortLogEventsByTime, normalizeLogEvent, logEventsEqual, currentLocalTimeHHMM, isValidTimeHHMM, type LogEventPayload } from '../utils/logEntryPayload.js'
|
import { buildLogEntryPayload, sortLogEventsByTime, normalizeLogEvent, hasUnsavedEventDraft, currentLocalTimeHHMM, isValidTimeHHMM, type LogEventPayload } from '../utils/logEntryPayload.js'
|
||||||
import EventTimeInput24h from './EventTimeInput24h.tsx'
|
import EventTimeInput24h from './EventTimeInput24h.tsx'
|
||||||
import CourseDialInput from './CourseDialInput.tsx'
|
import CourseDialInput from './CourseDialInput.tsx'
|
||||||
import { degreesToCardinal } from '../utils/courseAngle.js'
|
import { degreesToCardinal } from '../utils/courseAngle.js'
|
||||||
@@ -42,6 +42,14 @@ import {
|
|||||||
import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
|
import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
|
||||||
import { computeFuelPerMotorHour, formatFuelPerMotorHour } from '../utils/fuelStats.js'
|
import { computeFuelPerMotorHour, formatFuelPerMotorHour } from '../utils/fuelStats.js'
|
||||||
import { useRegisterUnsavedChanges } from '../context/UnsavedChangesContext.tsx'
|
import { useRegisterUnsavedChanges } from '../context/UnsavedChangesContext.tsx'
|
||||||
|
import TankLiterInput from './TankLiterInput.tsx'
|
||||||
|
import {
|
||||||
|
computeEveningTankMaxLiters,
|
||||||
|
computeRefilledTankMaxLiters,
|
||||||
|
extractTankCapacitiesFromYacht,
|
||||||
|
formatTankLitersForInput,
|
||||||
|
type VesselTankCapacities
|
||||||
|
} from '../utils/tankCapacity.js'
|
||||||
|
|
||||||
function emptyTankLevels() {
|
function emptyTankLevels() {
|
||||||
return { morning: 0, refilled: 0, evening: 0, consumption: 0 }
|
return { morning: 0, refilled: 0, evening: 0, consumption: 0 }
|
||||||
@@ -50,6 +58,7 @@ function emptyTankLevels() {
|
|||||||
function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string {
|
function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string {
|
||||||
const fw = (decrypted.freshwater as Record<string, number> | undefined) ?? emptyTankLevels()
|
const fw = (decrypted.freshwater as Record<string, number> | undefined) ?? emptyTankLevels()
|
||||||
const fuel = (decrypted.fuel as Record<string, number> | undefined) ?? emptyTankLevels()
|
const fuel = (decrypted.fuel as Record<string, number> | undefined) ?? emptyTankLevels()
|
||||||
|
const gw = decrypted.greywater as { level?: number } | undefined
|
||||||
const trackDistance = decrypted.trackDistanceNm
|
const trackDistance = decrypted.trackDistanceNm
|
||||||
const trackSpeedMax = decrypted.trackSpeedMaxKn
|
const trackSpeedMax = decrypted.trackSpeedMaxKn
|
||||||
const trackSpeedAvg = decrypted.trackSpeedAvgKn
|
const trackSpeedAvg = decrypted.trackSpeedAvgKn
|
||||||
@@ -72,6 +81,7 @@ function fingerprintFromStoredEntry(decrypted: Record<string, unknown>): string
|
|||||||
evening: fuel.evening || 0,
|
evening: fuel.evening || 0,
|
||||||
consumption: fuel.consumption ?? 0
|
consumption: fuel.consumption ?? 0
|
||||||
},
|
},
|
||||||
|
greywater: gw ? { level: gw.level || 0 } : undefined,
|
||||||
trackDistanceNm:
|
trackDistanceNm:
|
||||||
trackDistance != null && trackDistance !== ''
|
trackDistance != null && trackDistance !== ''
|
||||||
? parseFloat(String(trackDistance))
|
? parseFloat(String(trackDistance))
|
||||||
@@ -145,6 +155,9 @@ export default function LogEntryEditor({
|
|||||||
const [fuelEvening, setFuelEvening] = useState('0')
|
const [fuelEvening, setFuelEvening] = useState('0')
|
||||||
const [fuelConsumption, setFuelConsumption] = useState('0')
|
const [fuelConsumption, setFuelConsumption] = useState('0')
|
||||||
|
|
||||||
|
const [greywaterLevel, setGreywaterLevel] = useState('0')
|
||||||
|
const [tankCapacities, setTankCapacities] = useState<VesselTankCapacities>({})
|
||||||
|
|
||||||
// Signatures
|
// Signatures
|
||||||
const [signSkipper, setSignSkipper] = useState<SignatureValue | ''>('')
|
const [signSkipper, setSignSkipper] = useState<SignatureValue | ''>('')
|
||||||
const [signCrew, setSignCrew] = useState<SignatureValue | ''>('')
|
const [signCrew, setSignCrew] = useState<SignatureValue | ''>('')
|
||||||
@@ -176,6 +189,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('')
|
||||||
@@ -201,6 +215,7 @@ export default function LogEntryEditor({
|
|||||||
const contentReadyRef = useRef(false)
|
const contentReadyRef = useRef(false)
|
||||||
const lastSignatureAlertHashRef = useRef<string | null>(null)
|
const lastSignatureAlertHashRef = useRef<string | null>(null)
|
||||||
const skipCrewSignClearRef = useRef(false)
|
const skipCrewSignClearRef = useRef(false)
|
||||||
|
const entryHashSeqRef = useRef(0)
|
||||||
const [editingEventIndex, setEditingEventIndex] = useState<number | null>(null)
|
const [editingEventIndex, setEditingEventIndex] = useState<number | null>(null)
|
||||||
|
|
||||||
const applyTrackStats = (waypoints: SavedTrack['waypoints']) => {
|
const applyTrackStats = (waypoints: SavedTrack['waypoints']) => {
|
||||||
@@ -247,6 +262,7 @@ export default function LogEntryEditor({
|
|||||||
evening: parseFloat(fuelEvening) || 0,
|
evening: parseFloat(fuelEvening) || 0,
|
||||||
consumption: parseFloat(fuelConsumption) || 0
|
consumption: parseFloat(fuelConsumption) || 0
|
||||||
},
|
},
|
||||||
|
greywater: { level: parseFloat(greywaterLevel) || 0 },
|
||||||
trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined,
|
trackDistanceNm: trackDistanceNm.trim() ? parseFloat(trackDistanceNm) : undefined,
|
||||||
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
|
trackSpeedMaxKn: trackSpeedMaxKn.trim() ? parseFloat(trackSpeedMaxKn) : undefined,
|
||||||
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
|
trackSpeedAvgKn: trackSpeedAvgKn.trim() ? parseFloat(trackSpeedAvgKn) : undefined,
|
||||||
@@ -257,6 +273,7 @@ export default function LogEntryEditor({
|
|||||||
date, dayOfTravel, departure, destination,
|
date, dayOfTravel, departure, destination,
|
||||||
fwMorning, fwRefilled, fwEvening, fwConsumption,
|
fwMorning, fwRefilled, fwEvening, fwConsumption,
|
||||||
fuelMorning, fuelRefilled, fuelEvening, fuelConsumption,
|
fuelMorning, fuelRefilled, fuelEvening, fuelConsumption,
|
||||||
|
greywaterLevel,
|
||||||
trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn, motorHours,
|
trackDistanceNm, trackSpeedMaxKn, trackSpeedAvgKn, motorHours,
|
||||||
events
|
events
|
||||||
])
|
])
|
||||||
@@ -266,6 +283,38 @@ export default function LogEntryEditor({
|
|||||||
[fuelConsumption, motorHours]
|
[fuelConsumption, motorHours]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const tankCapacityTooltip = t('logs.tank_capacity_tooltip')
|
||||||
|
|
||||||
|
const fwRefilledMax = useMemo(
|
||||||
|
() => computeRefilledTankMaxLiters(fwMorning, tankCapacities.freshwaterCapacityL),
|
||||||
|
[fwMorning, tankCapacities.freshwaterCapacityL]
|
||||||
|
)
|
||||||
|
|
||||||
|
const fwEveningMax = useMemo(
|
||||||
|
() =>
|
||||||
|
computeEveningTankMaxLiters(
|
||||||
|
fwMorning,
|
||||||
|
fwRefilled,
|
||||||
|
tankCapacities.freshwaterCapacityL
|
||||||
|
),
|
||||||
|
[fwMorning, fwRefilled, tankCapacities.freshwaterCapacityL]
|
||||||
|
)
|
||||||
|
|
||||||
|
const fuelRefilledMax = useMemo(
|
||||||
|
() => computeRefilledTankMaxLiters(fuelMorning, tankCapacities.fuelCapacityL),
|
||||||
|
[fuelMorning, tankCapacities.fuelCapacityL]
|
||||||
|
)
|
||||||
|
|
||||||
|
const fuelEveningMax = useMemo(
|
||||||
|
() =>
|
||||||
|
computeEveningTankMaxLiters(
|
||||||
|
fuelMorning,
|
||||||
|
fuelRefilled,
|
||||||
|
tankCapacities.fuelCapacityL
|
||||||
|
),
|
||||||
|
[fuelMorning, fuelRefilled, tankCapacities.fuelCapacityL]
|
||||||
|
)
|
||||||
|
|
||||||
const currentFingerprint = useMemo(() => {
|
const currentFingerprint = useMemo(() => {
|
||||||
const payload = buildPayloadForSigning()
|
const payload = buildPayloadForSigning()
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
@@ -303,13 +352,7 @@ export default function LogEntryEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasPendingEventForm = useMemo(() => {
|
const hasPendingEventForm = useMemo(() => {
|
||||||
if (!evTime.trim()) return false
|
return hasUnsavedEventDraft(buildEventFromForm(), editingEventIndex, events)
|
||||||
const draft = buildEventFromForm()
|
|
||||||
if (editingEventIndex !== null) {
|
|
||||||
const original = events[editingEventIndex]
|
|
||||||
return original ? !logEventsEqual(draft, original) : false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}, [
|
}, [
|
||||||
evTime, evMgk, evRwk, evWindPressure, evWindDirection, evWindStrength, evSeaState,
|
evTime, evMgk, evRwk, evWindPressure, evWindDirection, evWindStrength, evSeaState,
|
||||||
evWeatherIcon, evCurrent, evHeel, evSailsOrMotor, evLogReading, evDistance,
|
evWeatherIcon, evCurrent, evHeel, evSailsOrMotor, evLogReading, evDistance,
|
||||||
@@ -330,16 +373,27 @@ export default function LogEntryEditor({
|
|||||||
onBack()
|
onBack()
|
||||||
}
|
}
|
||||||
|
|
||||||
const persistEntryToDb = useCallback(async (eventsOverride?: LogEvent[]) => {
|
const persistEntryToDb = useCallback(async (
|
||||||
|
options?: LogEvent[] | {
|
||||||
|
eventsOverride?: LogEvent[]
|
||||||
|
signSkipper?: SignatureValue | ''
|
||||||
|
signCrew?: SignatureValue | ''
|
||||||
|
}
|
||||||
|
) => {
|
||||||
if (readOnly) return
|
if (readOnly) return
|
||||||
|
|
||||||
|
const normalized = Array.isArray(options) ? { eventsOverride: options } : (options ?? {})
|
||||||
|
const eventsOverride = normalized.eventsOverride
|
||||||
|
const skipperToSave = normalized.signSkipper !== undefined ? normalized.signSkipper : signSkipper
|
||||||
|
const crewToSave = normalized.signCrew !== undefined ? normalized.signCrew : signCrew
|
||||||
|
|
||||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
||||||
|
|
||||||
const entryData = {
|
const entryData = {
|
||||||
...buildPayloadForSigning(eventsOverride),
|
...buildPayloadForSigning(eventsOverride),
|
||||||
signSkipper: normalizedSerializedSignature(signSkipper),
|
signSkipper: normalizedSerializedSignature(skipperToSave),
|
||||||
signCrew: normalizedSerializedSignature(signCrew)
|
signCrew: normalizedSerializedSignature(crewToSave)
|
||||||
}
|
}
|
||||||
|
|
||||||
const encrypted = await encryptJson(entryData, masterKey)
|
const encrypted = await encryptJson(entryData, masterKey)
|
||||||
@@ -367,9 +421,14 @@ export default function LogEntryEditor({
|
|||||||
|
|
||||||
setSavedFingerprint(JSON.stringify({
|
setSavedFingerprint(JSON.stringify({
|
||||||
...buildPayloadForSigning(eventsOverride),
|
...buildPayloadForSigning(eventsOverride),
|
||||||
signSkipper: fingerprintSignature(signSkipper),
|
signSkipper: fingerprintSignature(skipperToSave),
|
||||||
signCrew: fingerprintSignature(signCrew)
|
signCrew: fingerprintSignature(crewToSave)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const hash = await hashEntryForSigning(buildPayloadForSigning(eventsOverride))
|
||||||
|
entryHashSeqRef.current += 1
|
||||||
|
setEntryHash(hash)
|
||||||
|
lockedContentHashRef.current = hasAnySignature(skipperToSave, crewToSave) ? hash : null
|
||||||
}, [
|
}, [
|
||||||
readOnly, logbookId, entryId, events, buildPayloadForSigning, signSkipper, signCrew
|
readOnly, logbookId, entryId, events, buildPayloadForSigning, signSkipper, signCrew
|
||||||
])
|
])
|
||||||
@@ -397,9 +456,11 @@ export default function LogEntryEditor({
|
|||||||
}, [logbookId])
|
}, [logbookId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const seq = ++entryHashSeqRef.current
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
hashEntryForSigning(buildPayloadForSigning()).then((hash) => {
|
hashEntryForSigning(buildPayloadForSigning()).then((hash) => {
|
||||||
if (!cancelled) setEntryHash(hash)
|
if (cancelled || seq !== entryHashSeqRef.current) return
|
||||||
|
setEntryHash(hash)
|
||||||
})
|
})
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
}, [buildPayloadForSigning])
|
}, [buildPayloadForSigning])
|
||||||
@@ -470,6 +531,7 @@ export default function LogEntryEditor({
|
|||||||
role: 'skipper'
|
role: 'skipper'
|
||||||
})
|
})
|
||||||
setSignSkipper(signature)
|
setSignSkipper(signature)
|
||||||
|
entryHashSeqRef.current += 1
|
||||||
setEntryHash(hash)
|
setEntryHash(hash)
|
||||||
lockedContentHashRef.current = hash
|
lockedContentHashRef.current = hash
|
||||||
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'skipper' })
|
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'skipper' })
|
||||||
@@ -488,6 +550,7 @@ export default function LogEntryEditor({
|
|||||||
role: 'crew'
|
role: 'crew'
|
||||||
})
|
})
|
||||||
setSignCrew(signature)
|
setSignCrew(signature)
|
||||||
|
entryHashSeqRef.current += 1
|
||||||
setEntryHash(hash)
|
setEntryHash(hash)
|
||||||
lockedContentHashRef.current = hash
|
lockedContentHashRef.current = hash
|
||||||
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'crew' })
|
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'crew' })
|
||||||
@@ -511,11 +574,59 @@ export default function LogEntryEditor({
|
|||||||
setFuelConsumption(cons >= 0 ? String(cons) : '0')
|
setFuelConsumption(cons >= 0 ? String(cons) : '0')
|
||||||
}, [fuelMorning, fuelRefilled, fuelEvening])
|
}, [fuelMorning, fuelRefilled, fuelEvening])
|
||||||
|
|
||||||
// Load Yacht Sails
|
const fwRefilledNoCapacity =
|
||||||
|
(tankCapacities.freshwaterCapacityL ?? 0) > 0 && fwRefilledMax == null
|
||||||
|
const fuelRefilledNoCapacity =
|
||||||
|
(tankCapacities.fuelCapacityL ?? 0) > 0 && fuelRefilledMax == null
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadYachtSails() {
|
const refilled = parseFloat(fwRefilled) || 0
|
||||||
if (readOnly && preloadedYacht?.sails) {
|
if (fwRefilledMax == null) {
|
||||||
setYachtSails(preloadedYacht.sails)
|
if (fwRefilledNoCapacity && refilled > 0) {
|
||||||
|
setFwRefilled(formatTankLitersForInput(0))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (refilled > fwRefilledMax) {
|
||||||
|
setFwRefilled(formatTankLitersForInput(fwRefilledMax))
|
||||||
|
}
|
||||||
|
}, [fwRefilledMax, fwRefilled, fwRefilledNoCapacity])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (fwEveningMax == null) return
|
||||||
|
const evening = parseFloat(fwEvening) || 0
|
||||||
|
if (evening > fwEveningMax) {
|
||||||
|
setFwEvening(formatTankLitersForInput(fwEveningMax))
|
||||||
|
}
|
||||||
|
}, [fwEveningMax, fwEvening])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const refilled = parseFloat(fuelRefilled) || 0
|
||||||
|
if (fuelRefilledMax == null) {
|
||||||
|
if (fuelRefilledNoCapacity && refilled > 0) {
|
||||||
|
setFuelRefilled(formatTankLitersForInput(0))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (refilled > fuelRefilledMax) {
|
||||||
|
setFuelRefilled(formatTankLitersForInput(fuelRefilledMax))
|
||||||
|
}
|
||||||
|
}, [fuelRefilledMax, fuelRefilled, fuelRefilledNoCapacity])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (fuelEveningMax == null) return
|
||||||
|
const evening = parseFloat(fuelEvening) || 0
|
||||||
|
if (evening > fuelEveningMax) {
|
||||||
|
setFuelEvening(formatTankLitersForInput(fuelEveningMax))
|
||||||
|
}
|
||||||
|
}, [fuelEveningMax, fuelEvening])
|
||||||
|
|
||||||
|
// Load yacht sails and tank capacities
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadYachtMeta() {
|
||||||
|
if (readOnly && preloadedYacht) {
|
||||||
|
if (preloadedYacht.sails) setYachtSails(preloadedYacht.sails)
|
||||||
|
setTankCapacities(extractTankCapacitiesFromYacht(preloadedYacht))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -525,16 +636,19 @@ export default function LogEntryEditor({
|
|||||||
const yacht = await db.yachts.get(logbookId)
|
const yacht = await db.yachts.get(logbookId)
|
||||||
if (yacht) {
|
if (yacht) {
|
||||||
const decrypted = await decryptJson(yacht.encryptedData, yacht.iv, yacht.tag, masterKey)
|
const decrypted = await decryptJson(yacht.encryptedData, yacht.iv, yacht.tag, masterKey)
|
||||||
if (decrypted && decrypted.sails && Array.isArray(decrypted.sails)) {
|
if (decrypted) {
|
||||||
setYachtSails(decrypted.sails)
|
if (decrypted.sails && Array.isArray(decrypted.sails)) {
|
||||||
|
setYachtSails(decrypted.sails)
|
||||||
|
}
|
||||||
|
setTankCapacities(extractTankCapacitiesFromYacht(decrypted))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load yacht sails in editor:', err)
|
console.error('Failed to load yacht meta in editor:', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
loadYachtSails()
|
loadYachtMeta()
|
||||||
}, [logbookId, preloadedYacht])
|
}, [logbookId, preloadedYacht, readOnly])
|
||||||
|
|
||||||
// Load entry details
|
// Load entry details
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -564,6 +678,11 @@ export default function LogEntryEditor({
|
|||||||
setFuelEvening(String(preloadedEntry.fuel.evening || 0))
|
setFuelEvening(String(preloadedEntry.fuel.evening || 0))
|
||||||
setFuelConsumption(String(preloadedEntry.fuel.consumption ?? 0))
|
setFuelConsumption(String(preloadedEntry.fuel.consumption ?? 0))
|
||||||
}
|
}
|
||||||
|
if (preloadedEntry.greywater) {
|
||||||
|
setGreywaterLevel(String(preloadedEntry.greywater.level || 0))
|
||||||
|
} else {
|
||||||
|
setGreywaterLevel('0')
|
||||||
|
}
|
||||||
|
|
||||||
setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '')
|
setSignSkipper(normalizeSignature(preloadedEntry.signSkipper) || '')
|
||||||
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
|
setSignCrew(normalizeSignature(preloadedEntry.signCrew) || '')
|
||||||
@@ -597,6 +716,11 @@ export default function LogEntryEditor({
|
|||||||
setFuelEvening(String(decrypted.fuel.evening || 0))
|
setFuelEvening(String(decrypted.fuel.evening || 0))
|
||||||
setFuelConsumption(String(decrypted.fuel.consumption ?? 0))
|
setFuelConsumption(String(decrypted.fuel.consumption ?? 0))
|
||||||
}
|
}
|
||||||
|
if (decrypted.greywater) {
|
||||||
|
setGreywaterLevel(String(decrypted.greywater.level || 0))
|
||||||
|
} else {
|
||||||
|
setGreywaterLevel('0')
|
||||||
|
}
|
||||||
|
|
||||||
setSignSkipper(normalizeSignature(decrypted.signSkipper) || '')
|
setSignSkipper(normalizeSignature(decrypted.signSkipper) || '')
|
||||||
setSignCrew(normalizeSignature(decrypted.signCrew) || '')
|
setSignCrew(normalizeSignature(decrypted.signCrew) || '')
|
||||||
@@ -842,6 +966,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 +992,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 +1020,7 @@ export default function LogEntryEditor({
|
|||||||
setEvRemarks('')
|
setEvRemarks('')
|
||||||
setEvLocationName('')
|
setEvLocationName('')
|
||||||
setEditingEventIndex(null)
|
setEditingEventIndex(null)
|
||||||
|
setSailsPickerExpanded(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const fillEventForm = (ev: LogEvent) => {
|
const fillEventForm = (ev: LogEvent) => {
|
||||||
@@ -907,10 +1044,23 @@ export default function LogEntryEditor({
|
|||||||
setEvLocationName('')
|
setEvLocationName('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolveSignaturesAfterContentChange = (skipperOnly = false) => {
|
||||||
|
const hadSkipper = !!signSkipper
|
||||||
|
const hadCrew = !!signCrew
|
||||||
|
const cleared = hadSkipper || (hadCrew && !skipperOnly)
|
||||||
|
skipCrewSignClearRef.current = skipperOnly
|
||||||
|
const nextSkipper: SignatureValue | '' = hadSkipper ? '' : signSkipper
|
||||||
|
const nextCrew: SignatureValue | '' = hadCrew && !skipperOnly ? '' : signCrew
|
||||||
|
if (cleared) {
|
||||||
|
if (hadSkipper) setSignSkipper('')
|
||||||
|
if (hadCrew && !skipperOnly) setSignCrew('')
|
||||||
|
lockedContentHashRef.current = null
|
||||||
|
}
|
||||||
|
return { signSkipper: nextSkipper, signCrew: nextCrew, cleared }
|
||||||
|
}
|
||||||
|
|
||||||
const markSkipperSignatureClearedForEventChange = () => {
|
const markSkipperSignatureClearedForEventChange = () => {
|
||||||
if (!signSkipper) return
|
resolveSignaturesAfterContentChange(true)
|
||||||
skipCrewSignClearRef.current = true
|
|
||||||
setSignSkipper('')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEditEvent = (index: number) => {
|
const handleEditEvent = (index: number) => {
|
||||||
@@ -1000,11 +1150,20 @@ export default function LogEntryEditor({
|
|||||||
if (readOnly) return
|
if (readOnly) return
|
||||||
|
|
||||||
let eventsToSave = events
|
let eventsToSave = events
|
||||||
|
let signaturesForSave: { signSkipper: SignatureValue | ''; signCrew: SignatureValue | '' } | undefined
|
||||||
|
|
||||||
if (hasPendingEventForm) {
|
if (hasPendingEventForm) {
|
||||||
const isEdit = editingEventIndex !== null
|
const isEdit = editingEventIndex !== null
|
||||||
if (isEdit && signSkipper) {
|
const resolved = resolveSignaturesAfterContentChange(isEdit)
|
||||||
markSkipperSignatureClearedForEventChange()
|
signaturesForSave = {
|
||||||
|
signSkipper: resolved.signSkipper,
|
||||||
|
signCrew: resolved.signCrew
|
||||||
|
}
|
||||||
|
if (resolved.cleared) {
|
||||||
|
void showAlertRef.current(
|
||||||
|
isEdit ? t('logs.sign_cleared_skipper_re_sign') : t('logs.sign_cleared_re_sign'),
|
||||||
|
isEdit ? t('logs.sign_cleared_skipper_re_sign_title') : t('logs.sign_cleared_re_sign_title')
|
||||||
|
)
|
||||||
}
|
}
|
||||||
eventsToSave = applyEventFormToEvents(buildEventFromForm())
|
eventsToSave = applyEventFormToEvents(buildEventFromForm())
|
||||||
setEvents(eventsToSave)
|
setEvents(eventsToSave)
|
||||||
@@ -1018,7 +1177,10 @@ export default function LogEntryEditor({
|
|||||||
setSuccess(false)
|
setSuccess(false)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await persistEntryToDb(eventsToSave)
|
await persistEntryToDb({
|
||||||
|
eventsOverride: eventsToSave,
|
||||||
|
...signaturesForSave
|
||||||
|
})
|
||||||
|
|
||||||
setSuccess(true)
|
setSuccess(true)
|
||||||
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
|
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
|
||||||
@@ -1156,41 +1318,35 @@ export default function LogEntryEditor({
|
|||||||
<h3>{t('logs.freshwater')}</h3>
|
<h3>{t('logs.freshwater')}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="consumption-grid">
|
<div className="consumption-grid">
|
||||||
|
<TankLiterInput
|
||||||
|
id="fw-morning"
|
||||||
|
label={t('logs.morning')}
|
||||||
|
value={fwMorning}
|
||||||
|
onChange={setFwMorning}
|
||||||
|
maxLiters={tankCapacities.freshwaterCapacityL}
|
||||||
|
disabled={saving || readOnly}
|
||||||
|
titleTooltip={tankCapacityTooltip}
|
||||||
|
/>
|
||||||
|
<TankLiterInput
|
||||||
|
id="fw-refilled"
|
||||||
|
label={t('logs.refilled')}
|
||||||
|
value={fwRefilled}
|
||||||
|
onChange={setFwRefilled}
|
||||||
|
maxLiters={fwRefilledMax}
|
||||||
|
disabled={saving || readOnly || fwRefilledNoCapacity}
|
||||||
|
titleTooltip={tankCapacityTooltip}
|
||||||
|
/>
|
||||||
|
<TankLiterInput
|
||||||
|
id="fw-evening"
|
||||||
|
label={t('logs.evening')}
|
||||||
|
value={fwEvening}
|
||||||
|
onChange={setFwEvening}
|
||||||
|
maxLiters={fwEveningMax}
|
||||||
|
disabled={saving || readOnly}
|
||||||
|
titleTooltip={tankCapacityTooltip}
|
||||||
|
/>
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<label>{t('logs.morning')}</label>
|
<label title={tankCapacityTooltip}>{t('logs.consumption')} (L)</label>
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="input-text"
|
|
||||||
value={fwMorning}
|
|
||||||
onChange={(e) => setFwMorning(e.target.value)}
|
|
||||||
disabled={saving || readOnly}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="input-group">
|
|
||||||
<label>{t('logs.refilled')}</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="input-text"
|
|
||||||
value={fwRefilled}
|
|
||||||
onChange={(e) => setFwRefilled(e.target.value)}
|
|
||||||
disabled={saving || readOnly}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="input-group">
|
|
||||||
<label>{t('logs.evening')}</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="input-text"
|
|
||||||
value={fwEvening}
|
|
||||||
onChange={(e) => setFwEvening(e.target.value)}
|
|
||||||
disabled={saving || readOnly}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="input-group">
|
|
||||||
<label>{t('logs.consumption')} (L)</label>
|
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
className="input-text consumption-value"
|
className="input-text consumption-value"
|
||||||
@@ -1198,6 +1354,7 @@ export default function LogEntryEditor({
|
|||||||
readOnly
|
readOnly
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
aria-readonly="true"
|
aria-readonly="true"
|
||||||
|
title={tankCapacityTooltip}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1210,41 +1367,35 @@ export default function LogEntryEditor({
|
|||||||
<h3>{t('logs.fuel')}</h3>
|
<h3>{t('logs.fuel')}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="consumption-grid">
|
<div className="consumption-grid">
|
||||||
|
<TankLiterInput
|
||||||
|
id="fuel-morning"
|
||||||
|
label={t('logs.morning')}
|
||||||
|
value={fuelMorning}
|
||||||
|
onChange={setFuelMorning}
|
||||||
|
maxLiters={tankCapacities.fuelCapacityL}
|
||||||
|
disabled={saving || readOnly}
|
||||||
|
titleTooltip={tankCapacityTooltip}
|
||||||
|
/>
|
||||||
|
<TankLiterInput
|
||||||
|
id="fuel-refilled"
|
||||||
|
label={t('logs.refilled')}
|
||||||
|
value={fuelRefilled}
|
||||||
|
onChange={setFuelRefilled}
|
||||||
|
maxLiters={fuelRefilledMax}
|
||||||
|
disabled={saving || readOnly || fuelRefilledNoCapacity}
|
||||||
|
titleTooltip={tankCapacityTooltip}
|
||||||
|
/>
|
||||||
|
<TankLiterInput
|
||||||
|
id="fuel-evening"
|
||||||
|
label={t('logs.evening')}
|
||||||
|
value={fuelEvening}
|
||||||
|
onChange={setFuelEvening}
|
||||||
|
maxLiters={fuelEveningMax}
|
||||||
|
disabled={saving || readOnly}
|
||||||
|
titleTooltip={tankCapacityTooltip}
|
||||||
|
/>
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<label>{t('logs.morning')}</label>
|
<label title={tankCapacityTooltip}>{t('logs.consumption')} (L)</label>
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="input-text"
|
|
||||||
value={fuelMorning}
|
|
||||||
onChange={(e) => setFuelMorning(e.target.value)}
|
|
||||||
disabled={saving || readOnly}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="input-group">
|
|
||||||
<label>{t('logs.refilled')}</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="input-text"
|
|
||||||
value={fuelRefilled}
|
|
||||||
onChange={(e) => setFuelRefilled(e.target.value)}
|
|
||||||
disabled={saving || readOnly}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="input-group">
|
|
||||||
<label>{t('logs.evening')}</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="input-text"
|
|
||||||
value={fuelEvening}
|
|
||||||
onChange={(e) => setFuelEvening(e.target.value)}
|
|
||||||
disabled={saving || readOnly}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="input-group">
|
|
||||||
<label>{t('logs.consumption')} (L)</label>
|
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
className="input-text consumption-value"
|
className="input-text consumption-value"
|
||||||
@@ -1252,11 +1403,12 @@ export default function LogEntryEditor({
|
|||||||
readOnly
|
readOnly
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
aria-readonly="true"
|
aria-readonly="true"
|
||||||
|
title={tankCapacityTooltip}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<label>{t('logs.fuel_per_motor_hour')}</label>
|
<label title={tankCapacityTooltip}>{t('logs.fuel_per_motor_hour')}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="input-text consumption-value"
|
className="input-text consumption-value"
|
||||||
@@ -1268,10 +1420,30 @@ export default function LogEntryEditor({
|
|||||||
readOnly
|
readOnly
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
aria-readonly="true"
|
aria-readonly="true"
|
||||||
|
title={tankCapacityTooltip}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Greywater card */}
|
||||||
|
<div className="form-card">
|
||||||
|
<div className="form-header">
|
||||||
|
<Compass size={20} className="form-icon" />
|
||||||
|
<h3>{t('logs.greywater')}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="consumption-grid">
|
||||||
|
<TankLiterInput
|
||||||
|
id="greywater-level"
|
||||||
|
label={t('logs.greywater_level')}
|
||||||
|
value={greywaterLevel}
|
||||||
|
onChange={setGreywaterLevel}
|
||||||
|
maxLiters={tankCapacities.greywaterCapacityL}
|
||||||
|
disabled={saving || readOnly}
|
||||||
|
titleTooltip={tankCapacityTooltip}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Section 3: Event Journal Entries */}
|
{/* Section 3: Event Journal Entries */}
|
||||||
@@ -1559,25 +1731,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 +1745,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"
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react'
|
import React, { useState, useEffect, useRef, useMemo } 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'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
import { logoutUser } from '../services/auth.js'
|
import { logoutUser } from '../services/auth.js'
|
||||||
import { useDialog } from './ModalDialog.tsx'
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, User, Wifi, WifiOff } from 'lucide-react'
|
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, User, Wifi, WifiOff, Search, X, CalendarDays, CaseSensitive, ArrowUp, ArrowDown } from 'lucide-react'
|
||||||
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
|
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
|
||||||
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
|
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
|
||||||
|
|
||||||
@@ -18,6 +17,46 @@ interface LogbookDashboardProps {
|
|||||||
onOpenProfile: () => void
|
onOpenProfile: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function logbookMatchesFilter(lb: DecryptedLogbook, query: string, locale: string): boolean {
|
||||||
|
const q = query.trim().toLowerCase()
|
||||||
|
if (!q) return true
|
||||||
|
|
||||||
|
if (lb.title.toLowerCase().includes(q)) return true
|
||||||
|
|
||||||
|
const updated = new Date(lb.updatedAt)
|
||||||
|
const year = updated.getFullYear().toString()
|
||||||
|
if (year.includes(q)) return true
|
||||||
|
|
||||||
|
const dateLabel = updated.toLocaleDateString(locale, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
}).toLowerCase()
|
||||||
|
if (dateLabel.includes(q)) return true
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogbookSortKey = 'name' | 'date'
|
||||||
|
type LogbookSortDirection = 'asc' | 'desc'
|
||||||
|
|
||||||
|
function sortLogbooks(
|
||||||
|
items: DecryptedLogbook[],
|
||||||
|
sortBy: LogbookSortKey,
|
||||||
|
direction: LogbookSortDirection,
|
||||||
|
locale: string
|
||||||
|
): DecryptedLogbook[] {
|
||||||
|
const sorted = [...items]
|
||||||
|
sorted.sort((a, b) => {
|
||||||
|
const cmp =
|
||||||
|
sortBy === 'name'
|
||||||
|
? a.title.localeCompare(b.title, locale, { sensitivity: 'base' })
|
||||||
|
: new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
|
||||||
|
return direction === 'asc' ? cmp : -cmp
|
||||||
|
})
|
||||||
|
return sorted
|
||||||
|
}
|
||||||
|
|
||||||
export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProfile }: LogbookDashboardProps) {
|
export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProfile }: LogbookDashboardProps) {
|
||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
const { showConfirm } = useDialog()
|
const { showConfirm } = useDialog()
|
||||||
@@ -29,11 +68,14 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [filterQuery, setFilterQuery] = useState('')
|
||||||
|
const [sortBy, setSortBy] = useState<LogbookSortKey>('date')
|
||||||
|
const [sortDirection, setSortDirection] = useState<LogbookSortDirection>('desc')
|
||||||
|
const filterInputRef = useRef<HTMLInputElement>(null)
|
||||||
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(() => {
|
||||||
@@ -158,6 +200,25 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
const ownedLogbooks = logbooks.filter((lb) => !lb.isShared)
|
const ownedLogbooks = logbooks.filter((lb) => !lb.isShared)
|
||||||
const sharedLogbooks = logbooks.filter((lb) => lb.isShared)
|
const sharedLogbooks = logbooks.filter((lb) => lb.isShared)
|
||||||
|
|
||||||
|
const filterActive = filterQuery.trim().length > 0
|
||||||
|
const filteredOwnedLogbooks = useMemo(
|
||||||
|
() => ownedLogbooks.filter((lb) => logbookMatchesFilter(lb, filterQuery, i18n.language)),
|
||||||
|
[ownedLogbooks, filterQuery, i18n.language]
|
||||||
|
)
|
||||||
|
const filteredSharedLogbooks = useMemo(
|
||||||
|
() => sharedLogbooks.filter((lb) => logbookMatchesFilter(lb, filterQuery, i18n.language)),
|
||||||
|
[sharedLogbooks, filterQuery, i18n.language]
|
||||||
|
)
|
||||||
|
const sortedOwnedLogbooks = useMemo(
|
||||||
|
() => sortLogbooks(filteredOwnedLogbooks, sortBy, sortDirection, i18n.language),
|
||||||
|
[filteredOwnedLogbooks, sortBy, sortDirection, i18n.language]
|
||||||
|
)
|
||||||
|
const sortedSharedLogbooks = useMemo(
|
||||||
|
() => sortLogbooks(filteredSharedLogbooks, sortBy, sortDirection, i18n.language),
|
||||||
|
[filteredSharedLogbooks, sortBy, sortDirection, i18n.language]
|
||||||
|
)
|
||||||
|
const filteredLogbookCount = sortedOwnedLogbooks.length + sortedSharedLogbooks.length
|
||||||
|
|
||||||
const renderLogbookCard = (lb: DecryptedLogbook) => {
|
const renderLogbookCard = (lb: DecryptedLogbook) => {
|
||||||
const isEditingTitle = editingLogbookId === lb.id
|
const isEditingTitle = editingLogbookId === lb.id
|
||||||
|
|
||||||
@@ -272,11 +333,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>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -300,6 +377,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
onClick={onOpenProfile}
|
onClick={onOpenProfile}
|
||||||
title={t('dashboard.open_profile', { name: username })}
|
title={t('dashboard.open_profile', { name: username })}
|
||||||
aria-label={t('dashboard.open_profile', { name: username })}
|
aria-label={t('dashboard.open_profile', { name: username })}
|
||||||
|
data-tour="nav-profile"
|
||||||
>
|
>
|
||||||
<User size={18} aria-hidden="true" />
|
<User size={18} aria-hidden="true" />
|
||||||
<span className="skipper-badge__name">{username}</span>
|
<span className="skipper-badge__name">{username}</span>
|
||||||
@@ -361,17 +439,115 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
|
|||||||
) : logbooks.length === 0 ? (
|
) : logbooks.length === 0 ? (
|
||||||
<div className="dashboard-status-msg glass">{t('dashboard.no_logbooks')}</div>
|
<div className="dashboard-status-msg glass">{t('dashboard.no_logbooks')}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="logbook-sections">
|
<>
|
||||||
{ownedLogbooks.length > 0 && renderLogbookSection(
|
<div className="dashboard-list-controls">
|
||||||
sharedLogbooks.length > 0 ? t('dashboard.section_owned') : t('dashboard.title'),
|
<div className="dashboard-filter-bar">
|
||||||
ownedLogbooks
|
<label className="dashboard-filter-label" htmlFor="logbook-list-filter">
|
||||||
|
{t('dashboard.filter_label')}
|
||||||
|
</label>
|
||||||
|
<div className="dashboard-filter-input-wrap">
|
||||||
|
<Search size={18} className="dashboard-filter-icon" aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
ref={filterInputRef}
|
||||||
|
id="logbook-list-filter"
|
||||||
|
type="search"
|
||||||
|
className="input-text dashboard-filter-input"
|
||||||
|
placeholder={t('dashboard.filter_placeholder')}
|
||||||
|
value={filterQuery}
|
||||||
|
onChange={(e) => setFilterQuery(e.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
aria-describedby={filterActive ? 'logbook-filter-status' : undefined}
|
||||||
|
/>
|
||||||
|
{filterActive && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="dashboard-filter-clear"
|
||||||
|
onClick={() => {
|
||||||
|
setFilterQuery('')
|
||||||
|
filterInputRef.current?.focus()
|
||||||
|
}}
|
||||||
|
title={t('dashboard.filter_clear')}
|
||||||
|
aria-label={t('dashboard.filter_clear')}
|
||||||
|
>
|
||||||
|
<X size={16} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{filterActive && (
|
||||||
|
<p id="logbook-filter-status" className="dashboard-filter-meta" role="status">
|
||||||
|
{t('dashboard.filter_results', { count: filteredLogbookCount })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dashboard-sort-bar">
|
||||||
|
<span className="dashboard-sort-label">{t('dashboard.sort_label')}</span>
|
||||||
|
<div className="dashboard-sort-row">
|
||||||
|
<div className="dashboard-sort-group" role="group" aria-label={t('dashboard.sort_by_label')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`dashboard-sort-btn${sortBy === 'name' ? ' is-active' : ''}`}
|
||||||
|
onClick={() => setSortBy('name')}
|
||||||
|
aria-pressed={sortBy === 'name'}
|
||||||
|
aria-label={t('dashboard.sort_by_name')}
|
||||||
|
title={t('dashboard.sort_by_name')}
|
||||||
|
>
|
||||||
|
<CaseSensitive size={16} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`dashboard-sort-btn${sortBy === 'date' ? ' is-active' : ''}`}
|
||||||
|
onClick={() => setSortBy('date')}
|
||||||
|
aria-pressed={sortBy === 'date'}
|
||||||
|
aria-label={t('dashboard.sort_by_date')}
|
||||||
|
title={t('dashboard.sort_by_date')}
|
||||||
|
>
|
||||||
|
<CalendarDays size={16} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="dashboard-sort-group" role="group" aria-label={t('dashboard.sort_dir_label')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`dashboard-sort-btn${sortDirection === 'asc' ? ' is-active' : ''}`}
|
||||||
|
onClick={() => setSortDirection('asc')}
|
||||||
|
aria-pressed={sortDirection === 'asc'}
|
||||||
|
aria-label={sortBy === 'name' ? t('dashboard.sort_name_asc') : t('dashboard.sort_date_asc')}
|
||||||
|
title={sortBy === 'name' ? t('dashboard.sort_name_asc') : t('dashboard.sort_date_asc')}
|
||||||
|
>
|
||||||
|
<ArrowUp size={16} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`dashboard-sort-btn${sortDirection === 'desc' ? ' is-active' : ''}`}
|
||||||
|
onClick={() => setSortDirection('desc')}
|
||||||
|
aria-pressed={sortDirection === 'desc'}
|
||||||
|
aria-label={sortBy === 'name' ? t('dashboard.sort_name_desc') : t('dashboard.sort_date_desc')}
|
||||||
|
title={sortBy === 'name' ? t('dashboard.sort_name_desc') : t('dashboard.sort_date_desc')}
|
||||||
|
>
|
||||||
|
<ArrowDown size={16} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filterActive && filteredLogbookCount === 0 ? (
|
||||||
|
<div className="dashboard-status-msg glass">{t('dashboard.filter_no_results')}</div>
|
||||||
|
) : (
|
||||||
|
<div className="logbook-sections">
|
||||||
|
{sortedOwnedLogbooks.length > 0 && renderLogbookSection(
|
||||||
|
sortedSharedLogbooks.length > 0 ? t('dashboard.section_owned') : t('dashboard.title'),
|
||||||
|
sortedOwnedLogbooks
|
||||||
|
)}
|
||||||
|
{sortedSharedLogbooks.length > 0 && renderLogbookSection(
|
||||||
|
t('dashboard.section_shared'),
|
||||||
|
sortedSharedLogbooks,
|
||||||
|
t('dashboard.section_shared_hint')
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{sharedLogbooks.length > 0 && renderLogbookSection(
|
</>
|
||||||
t('dashboard.section_shared'),
|
|
||||||
sharedLogbooks,
|
|
||||||
t('dashboard.section_shared_hint')
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
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'
|
||||||
|
import {
|
||||||
|
enableCollaboratorChangePush,
|
||||||
|
isCollaboratorPushActive,
|
||||||
|
isPushSupported
|
||||||
|
} from '../services/pushNotifications.js'
|
||||||
|
import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
|
||||||
|
|
||||||
interface SettingsFormProps {
|
interface SettingsFormProps {
|
||||||
logbookId?: string | null
|
logbookId?: string | null
|
||||||
@@ -25,7 +26,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 +35,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 +44,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 +112,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 +128,6 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const loadCollaborators = async () => {
|
const loadCollaborators = async () => {
|
||||||
setLoadingCollabs(true)
|
setLoadingCollabs(true)
|
||||||
setCollabError(null)
|
setCollabError(null)
|
||||||
@@ -166,6 +157,43 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const promptPushAfterInviteCreated = async () => {
|
||||||
|
if (!isPushSupported()) return
|
||||||
|
if (await isCollaboratorPushActive()) return
|
||||||
|
|
||||||
|
const iosNeedsInstall = isIosDevice() && !isRunningStandalone()
|
||||||
|
|
||||||
|
if (iosNeedsInstall) {
|
||||||
|
await showAlert(
|
||||||
|
t('settings.invite_push_prompt_ios_message'),
|
||||||
|
t('settings.invite_push_prompt_title'),
|
||||||
|
t('settings.invite_push_prompt_later')
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const enable = await showConfirm(
|
||||||
|
t('settings.invite_push_prompt_message'),
|
||||||
|
t('settings.invite_push_prompt_title'),
|
||||||
|
t('settings.invite_push_prompt_enable'),
|
||||||
|
t('settings.invite_push_prompt_later')
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!enable) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await enableCollaboratorChangePush()
|
||||||
|
await showAlert(
|
||||||
|
t('settings.invite_push_prompt_success'),
|
||||||
|
t('settings.invite_push_prompt_title')
|
||||||
|
)
|
||||||
|
trackPlausibleEvent(PlausibleEvents.PUSH_ENABLED)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error('Failed to enable push after invite:', err)
|
||||||
|
await showAlert(err instanceof Error ? err.message : t('profile.push_error'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleGenerateInvite = async () => {
|
const handleGenerateInvite = async () => {
|
||||||
if (!logbookId) return
|
if (!logbookId) return
|
||||||
setGeneratingInvite(true)
|
setGeneratingInvite(true)
|
||||||
@@ -173,10 +201,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 +213,15 @@ 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) {
|
await promptPushAfterInviteCreated()
|
||||||
|
} 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 +250,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 +278,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 +336,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 +387,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>
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import React, { useCallback } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { clampTankLiters } from '../utils/tankCapacity.js'
|
||||||
|
|
||||||
|
interface TankLiterInputProps {
|
||||||
|
id?: string
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
maxLiters?: number
|
||||||
|
disabled?: boolean
|
||||||
|
titleTooltip?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseInputLiters(value: string): number {
|
||||||
|
const trimmed = value.trim().replace(',', '.')
|
||||||
|
if (!trimmed) return 0
|
||||||
|
const parsed = Number(trimmed)
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TankLiterInput({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
maxLiters,
|
||||||
|
disabled = false,
|
||||||
|
titleTooltip
|
||||||
|
}: TankLiterInputProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const useSlider = maxLiters != null && maxLiters > 0
|
||||||
|
|
||||||
|
const emitValue = useCallback(
|
||||||
|
(liters: number) => {
|
||||||
|
const clamped = clampTankLiters(liters, useSlider ? maxLiters : undefined)
|
||||||
|
const str =
|
||||||
|
Number.isInteger(clamped) ? String(clamped) : String(Number(clamped.toFixed(1)))
|
||||||
|
onChange(str)
|
||||||
|
},
|
||||||
|
[onChange, maxLiters, useSlider]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleNumberChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onChange(e.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNumberBlur = () => {
|
||||||
|
if (!useSlider) return
|
||||||
|
emitValue(parseInputLiters(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
emitValue(Number(e.target.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
const numericValue = parseInputLiters(value)
|
||||||
|
const sliderValue = useSlider ? clampTankLiters(numericValue, maxLiters) : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="input-group tank-liter-input">
|
||||||
|
<label htmlFor={id} title={titleTooltip}>{label}</label>
|
||||||
|
{useSlider && (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className="tank-liter-slider"
|
||||||
|
min={0}
|
||||||
|
max={maxLiters}
|
||||||
|
step={1}
|
||||||
|
value={sliderValue}
|
||||||
|
onChange={handleSliderChange}
|
||||||
|
disabled={disabled}
|
||||||
|
title={titleTooltip}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={maxLiters}
|
||||||
|
aria-valuenow={sliderValue}
|
||||||
|
aria-label={label}
|
||||||
|
/>
|
||||||
|
<div className="tank-liter-slider-hint" aria-hidden="true">
|
||||||
|
{t('logs.tank_slider_of_max', {
|
||||||
|
current: sliderValue,
|
||||||
|
max: maxLiters
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="number"
|
||||||
|
className="input-text"
|
||||||
|
value={value}
|
||||||
|
onChange={handleNumberChange}
|
||||||
|
onBlur={handleNumberBlur}
|
||||||
|
disabled={disabled}
|
||||||
|
min={0}
|
||||||
|
max={useSlider ? maxLiters : undefined}
|
||||||
|
step="any"
|
||||||
|
title={titleTooltip}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
@@ -436,6 +443,7 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
|||||||
</section>
|
</section>
|
||||||
) : profile ? (
|
) : profile ? (
|
||||||
<>
|
<>
|
||||||
|
<div data-tour="profile-preferences">
|
||||||
<section className="form-card">
|
<section className="form-card">
|
||||||
<div className="form-header">
|
<div className="form-header">
|
||||||
<User size={24} className="form-icon" />
|
<User size={24} className="form-icon" />
|
||||||
@@ -476,6 +484,9 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
|||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<UserProfilePreferences userId={profile.userId} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<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 +535,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>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -710,7 +726,7 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="form-card">
|
<section className="form-card profile-stats-section">
|
||||||
<div className="form-header">
|
<div className="form-header">
|
||||||
<BarChart2 size={24} className="form-icon" />
|
<BarChart2 size={24} className="form-icon" />
|
||||||
<div>
|
<div>
|
||||||
@@ -720,7 +736,7 @@ export default function UserProfilePage({ onBack, onLogout }: UserProfilePagePro
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(statsTotals || profile) && (
|
{(statsTotals || profile) && (
|
||||||
<div className="stats-kpi-grid">
|
<div className="stats-kpi-grid profile-stats-kpi-grid">
|
||||||
<KpiCard
|
<KpiCard
|
||||||
icon={<BookOpen size={20} />}
|
icon={<BookOpen size={20} />}
|
||||||
label={t('profile.stats_logbooks')}
|
label={t('profile.stats_logbooks')}
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
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 { saveAppearancePrefsToServer } from '../services/appearancePrefs.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()
|
||||||
|
void saveAppearancePrefsToServer(nextTheme, nextColorScheme).catch((err) => {
|
||||||
|
console.warn('Failed to save appearance prefs to server:', err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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" />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { encryptJson, decryptJson } from '../services/crypto.js'
|
|||||||
import { syncLogbook } from '../services/sync.js'
|
import { syncLogbook } from '../services/sync.js'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
import { Ship, Save, Check, Plus, X, Camera, Trash2 } from 'lucide-react'
|
import { Ship, Save, Check, Plus, X, Camera, Trash2 } from 'lucide-react'
|
||||||
|
import { parseOptionalTankLiters, tankCapacityInputFromStored } from '../utils/tankCapacity.js'
|
||||||
|
|
||||||
interface VesselFormProps {
|
interface VesselFormProps {
|
||||||
logbookId: string
|
logbookId: string
|
||||||
@@ -47,6 +48,9 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
|
|||||||
const [mmsi, setMmsi] = useState('')
|
const [mmsi, setMmsi] = useState('')
|
||||||
const [sails, setSails] = useState<string[]>([])
|
const [sails, setSails] = useState<string[]>([])
|
||||||
const [newSailName, setNewSailName] = useState('')
|
const [newSailName, setNewSailName] = useState('')
|
||||||
|
const [freshwaterCapacityL, setFreshwaterCapacityL] = useState('')
|
||||||
|
const [fuelCapacityL, setFuelCapacityL] = useState('')
|
||||||
|
const [greywaterCapacityL, setGreywaterCapacityL] = useState('')
|
||||||
|
|
||||||
const fileInputRef = React.useRef<HTMLInputElement>(null)
|
const fileInputRef = React.useRef<HTMLInputElement>(null)
|
||||||
const [photo, setPhoto] = useState<string | null>(null)
|
const [photo, setPhoto] = useState<string | null>(null)
|
||||||
@@ -78,6 +82,9 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
|
|||||||
setMmsi(preloadedData.mmsi || '')
|
setMmsi(preloadedData.mmsi || '')
|
||||||
setSails(preloadedData.sails || [])
|
setSails(preloadedData.sails || [])
|
||||||
setPhoto(preloadedData.photo || null)
|
setPhoto(preloadedData.photo || null)
|
||||||
|
setFreshwaterCapacityL(tankCapacityInputFromStored(preloadedData.freshwaterCapacityL))
|
||||||
|
setFuelCapacityL(tankCapacityInputFromStored(preloadedData.fuelCapacityL))
|
||||||
|
setGreywaterCapacityL(tankCapacityInputFromStored(preloadedData.greywaterCapacityL))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +110,9 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
|
|||||||
setMmsi(decrypted.mmsi || '')
|
setMmsi(decrypted.mmsi || '')
|
||||||
setSails(decrypted.sails || [])
|
setSails(decrypted.sails || [])
|
||||||
setPhoto(decrypted.photo || null)
|
setPhoto(decrypted.photo || null)
|
||||||
|
setFreshwaterCapacityL(tankCapacityInputFromStored(decrypted.freshwaterCapacityL))
|
||||||
|
setFuelCapacityL(tankCapacityInputFromStored(decrypted.fuelCapacityL))
|
||||||
|
setGreywaterCapacityL(tankCapacityInputFromStored(decrypted.greywaterCapacityL))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -201,12 +211,19 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
|
|||||||
let parsedLengthM: number | undefined
|
let parsedLengthM: number | undefined
|
||||||
let parsedDraftM: number | undefined
|
let parsedDraftM: number | undefined
|
||||||
let parsedAirDraftM: number | undefined
|
let parsedAirDraftM: number | undefined
|
||||||
|
let parsedFreshwaterCapacityL: number | undefined
|
||||||
|
let parsedFuelCapacityL: number | undefined
|
||||||
|
let parsedGreywaterCapacityL: number | undefined
|
||||||
try {
|
try {
|
||||||
parsedLengthM = parseOptionalMetricMeters(lengthM)
|
parsedLengthM = parseOptionalMetricMeters(lengthM)
|
||||||
parsedDraftM = parseOptionalMetricMeters(draftM)
|
parsedDraftM = parseOptionalMetricMeters(draftM)
|
||||||
parsedAirDraftM = parseOptionalMetricMeters(airDraftM)
|
parsedAirDraftM = parseOptionalMetricMeters(airDraftM)
|
||||||
} catch {
|
parsedFreshwaterCapacityL = parseOptionalTankLiters(freshwaterCapacityL)
|
||||||
setError(t('vessel.invalid_metric'))
|
parsedFuelCapacityL = parseOptionalTankLiters(fuelCapacityL)
|
||||||
|
parsedGreywaterCapacityL = parseOptionalTankLiters(greywaterCapacityL)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : ''
|
||||||
|
setError(msg === 'invalid_tank_liters' ? t('vessel.invalid_tank_liters') : t('vessel.invalid_metric'))
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -217,6 +234,9 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
|
|||||||
lengthM: parsedLengthM,
|
lengthM: parsedLengthM,
|
||||||
draftM: parsedDraftM,
|
draftM: parsedDraftM,
|
||||||
airDraftM: parsedAirDraftM,
|
airDraftM: parsedAirDraftM,
|
||||||
|
freshwaterCapacityL: parsedFreshwaterCapacityL,
|
||||||
|
fuelCapacityL: parsedFuelCapacityL,
|
||||||
|
greywaterCapacityL: parsedGreywaterCapacityL,
|
||||||
homePort: homePort.trim(),
|
homePort: homePort.trim(),
|
||||||
charterCompany: charterCompany.trim(),
|
charterCompany: charterCompany.trim(),
|
||||||
owner: owner.trim(),
|
owner: owner.trim(),
|
||||||
@@ -480,6 +500,49 @@ export default function VesselForm({ logbookId, readOnly = false, preloadedData
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="vessel-tanks-section">
|
||||||
|
<h3>{t('vessel.tanks_section')}</h3>
|
||||||
|
<p className="vessel-tanks-help">{t('vessel.tanks_help')}</p>
|
||||||
|
<div className="vessel-tanks-grid">
|
||||||
|
<div className="input-group">
|
||||||
|
<label>{t('vessel.freshwater_capacity_l')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
className="input-text"
|
||||||
|
value={freshwaterCapacityL}
|
||||||
|
onChange={(e) => setFreshwaterCapacityL(e.target.value)}
|
||||||
|
disabled={saving || readOnly}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="input-group">
|
||||||
|
<label>{t('vessel.fuel_capacity_l')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
className="input-text"
|
||||||
|
value={fuelCapacityL}
|
||||||
|
onChange={(e) => setFuelCapacityL(e.target.value)}
|
||||||
|
disabled={saving || readOnly}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="input-group">
|
||||||
|
<label>{t('vessel.greywater_capacity_l')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
className="input-text"
|
||||||
|
value={greywaterCapacityL}
|
||||||
|
onChange={(e) => setGreywaterCapacityL(e.target.value)}
|
||||||
|
disabled={saving || readOnly}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="sails-section">
|
<div className="sails-section">
|
||||||
<h3>{t('vessel.sails_list')}</h3>
|
<h3>{t('vessel.sails_list')}</h3>
|
||||||
<p className="help-text">{t('vessel.sails_help')}</p>
|
<p className="help-text">{t('vessel.sails_help')}</p>
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
DEMO_EXCLUDED_STEPS,
|
||||||
|
DEMO_STEP_ORDER,
|
||||||
|
FULL_STEP_ORDER,
|
||||||
|
getTourScrollRetryDelays,
|
||||||
|
getTourTargetRetryDelay,
|
||||||
|
tourStepOpensEntry
|
||||||
|
} from './AppTourContext.tsx'
|
||||||
|
|
||||||
|
describe('AppTourContext step order', () => {
|
||||||
|
it('includes profile steps before finish in full tour', () => {
|
||||||
|
const profileIndex = FULL_STEP_ORDER.indexOf('nav_profile')
|
||||||
|
const prefsIndex = FULL_STEP_ORDER.indexOf('profile_preferences')
|
||||||
|
const finishIndex = FULL_STEP_ORDER.indexOf('finish')
|
||||||
|
|
||||||
|
expect(profileIndex).toBeGreaterThan(FULL_STEP_ORDER.indexOf('nav_feedback'))
|
||||||
|
expect(prefsIndex).toBe(profileIndex + 1)
|
||||||
|
expect(finishIndex).toBe(prefsIndex + 1)
|
||||||
|
expect(FULL_STEP_ORDER).toHaveLength(12)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('excludes profile, stats and feedback from demo tour', () => {
|
||||||
|
for (const step of DEMO_EXCLUDED_STEPS) {
|
||||||
|
expect(DEMO_STEP_ORDER).not.toContain(step)
|
||||||
|
}
|
||||||
|
expect(DEMO_STEP_ORDER).toContain('finish')
|
||||||
|
expect(DEMO_STEP_ORDER).toHaveLength(FULL_STEP_ORDER.length - DEMO_EXCLUDED_STEPS.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('only opens entry editor on entry_track step', () => {
|
||||||
|
expect(tourStepOpensEntry('entry_open')).toBe(false)
|
||||||
|
expect(tourStepOpensEntry('entry_list')).toBe(false)
|
||||||
|
expect(tourStepOpensEntry('entry_track')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retries scroll for entry_track while editor mounts', () => {
|
||||||
|
expect(getTourTargetRetryDelay('entry_track')).toBeGreaterThanOrEqual(400)
|
||||||
|
expect(getTourScrollRetryDelays('entry_track').length).toBeGreaterThan(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -29,12 +29,17 @@ export type TourStepId =
|
|||||||
| 'nav_crew'
|
| 'nav_crew'
|
||||||
| 'nav_stats'
|
| 'nav_stats'
|
||||||
| 'nav_feedback'
|
| 'nav_feedback'
|
||||||
|
| 'nav_profile'
|
||||||
|
| 'profile_preferences'
|
||||||
| 'finish'
|
| 'finish'
|
||||||
|
|
||||||
interface TourNavigation {
|
interface TourNavigation {
|
||||||
setActiveTab: (tab: AppTab) => void
|
setActiveTab: (tab: AppTab) => void
|
||||||
setSelectedEntryId: (entryId: string | null) => void
|
setSelectedEntryId: (entryId: string | null) => void
|
||||||
setFeedbackOpen: (open: boolean) => void
|
setFeedbackOpen: (open: boolean) => void
|
||||||
|
setLogbookActive: (active: boolean) => void
|
||||||
|
setProfileOpen: (open: boolean) => void
|
||||||
|
ensureLogbookForTour?: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DemoTourContext {
|
interface DemoTourContext {
|
||||||
@@ -47,6 +52,7 @@ interface AppTourContextValue {
|
|||||||
currentStepId: TourStepId | null
|
currentStepId: TourStepId | null
|
||||||
currentStepIndex: number
|
currentStepIndex: number
|
||||||
totalSteps: number
|
totalSteps: number
|
||||||
|
layoutTick: number
|
||||||
startTour: (options?: { force?: boolean; demoMode?: boolean }) => void
|
startTour: (options?: { force?: boolean; demoMode?: boolean }) => void
|
||||||
stopTour: () => void
|
stopTour: () => void
|
||||||
restartTour: () => void
|
restartTour: () => void
|
||||||
@@ -58,7 +64,7 @@ interface AppTourContextValue {
|
|||||||
requestStartAfterLogin: () => void
|
requestStartAfterLogin: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const FULL_STEP_ORDER: TourStepId[] = [
|
export const FULL_STEP_ORDER: TourStepId[] = [
|
||||||
'welcome',
|
'welcome',
|
||||||
'nav_logs',
|
'nav_logs',
|
||||||
'entry_list',
|
'entry_list',
|
||||||
@@ -68,12 +74,33 @@ const FULL_STEP_ORDER: TourStepId[] = [
|
|||||||
'nav_crew',
|
'nav_crew',
|
||||||
'nav_stats',
|
'nav_stats',
|
||||||
'nav_feedback',
|
'nav_feedback',
|
||||||
|
'nav_profile',
|
||||||
|
'profile_preferences',
|
||||||
'finish'
|
'finish'
|
||||||
]
|
]
|
||||||
|
|
||||||
/** Public demo has no stats/feedback UI — skip those steps. */
|
/** Public demo has no stats/feedback/profile UI — skip those steps. */
|
||||||
const DEMO_EXCLUDED_STEPS: TourStepId[] = ['nav_stats', 'nav_feedback']
|
export const DEMO_EXCLUDED_STEPS: TourStepId[] = [
|
||||||
const DEMO_STEP_ORDER: TourStepId[] = FULL_STEP_ORDER.filter((id) => !DEMO_EXCLUDED_STEPS.includes(id))
|
'nav_stats',
|
||||||
|
'nav_feedback',
|
||||||
|
'nav_profile',
|
||||||
|
'profile_preferences'
|
||||||
|
]
|
||||||
|
|
||||||
|
export const DEMO_STEP_ORDER: TourStepId[] = FULL_STEP_ORDER.filter(
|
||||||
|
(id) => !DEMO_EXCLUDED_STEPS.includes(id)
|
||||||
|
)
|
||||||
|
|
||||||
|
const LOGBOOK_TOUR_STEPS = new Set<TourStepId>([
|
||||||
|
'nav_logs',
|
||||||
|
'entry_list',
|
||||||
|
'entry_open',
|
||||||
|
'entry_track',
|
||||||
|
'nav_vessel',
|
||||||
|
'nav_crew',
|
||||||
|
'nav_stats',
|
||||||
|
'nav_feedback'
|
||||||
|
])
|
||||||
|
|
||||||
function getStepOrder(demoMode: boolean): TourStepId[] {
|
function getStepOrder(demoMode: boolean): TourStepId[] {
|
||||||
return demoMode ? DEMO_STEP_ORDER : FULL_STEP_ORDER
|
return demoMode ? DEMO_STEP_ORDER : FULL_STEP_ORDER
|
||||||
@@ -87,7 +114,28 @@ const TARGET_BY_STEP: Partial<Record<TourStepId, string>> = {
|
|||||||
nav_vessel: '[data-tour="nav-vessel"]',
|
nav_vessel: '[data-tour="nav-vessel"]',
|
||||||
nav_crew: '[data-tour="nav-crew"]',
|
nav_crew: '[data-tour="nav-crew"]',
|
||||||
nav_stats: '[data-tour="stats-dashboard"]',
|
nav_stats: '[data-tour="stats-dashboard"]',
|
||||||
nav_feedback: '[data-tour="feedback-form"]'
|
nav_feedback: '[data-tour="feedback-form"]',
|
||||||
|
nav_profile: '[data-tour="nav-profile"]',
|
||||||
|
profile_preferences: '[data-tour="profile-preferences"]'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a tour step opens the first log entry editor (not the list card). */
|
||||||
|
export function tourStepOpensEntry(stepId: TourStepId): boolean {
|
||||||
|
return stepId === 'entry_track'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTourTargetDelay(stepId: TourStepId): number {
|
||||||
|
if (stepId === 'entry_track') return 400
|
||||||
|
if (stepId === 'nav_feedback') return 180
|
||||||
|
if (stepId === 'nav_profile' || stepId === 'profile_preferences') return 250
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extra scroll attempts while async UI (e.g. entry editor) mounts. */
|
||||||
|
export function getTourScrollRetryDelays(stepId: TourStepId): number[] {
|
||||||
|
if (stepId === 'entry_track') return [400, 700, 1100, 1600]
|
||||||
|
const initial = getTourTargetDelay(stepId)
|
||||||
|
return initial > 0 ? [initial] : [0]
|
||||||
}
|
}
|
||||||
|
|
||||||
const AppTourContext = createContext<AppTourContextValue | null>(null)
|
const AppTourContext = createContext<AppTourContextValue | null>(null)
|
||||||
@@ -97,6 +145,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
|||||||
const [stepIndex, setStepIndex] = useState(0)
|
const [stepIndex, setStepIndex] = useState(0)
|
||||||
const [pendingAfterLogin, setPendingAfterLogin] = useState(false)
|
const [pendingAfterLogin, setPendingAfterLogin] = useState(false)
|
||||||
const [isDemoTour, setIsDemoTour] = useState(false)
|
const [isDemoTour, setIsDemoTour] = useState(false)
|
||||||
|
const [layoutTick, setLayoutTick] = useState(0)
|
||||||
const navigationRef = useRef<TourNavigation | null>(null)
|
const navigationRef = useRef<TourNavigation | null>(null)
|
||||||
const demoContextRef = useRef<DemoTourContext | null>(null)
|
const demoContextRef = useRef<DemoTourContext | null>(null)
|
||||||
const tourModeRef = useRef<{ demoMode: boolean }>({ demoMode: false })
|
const tourModeRef = useRef<{ demoMode: boolean }>({ demoMode: false })
|
||||||
@@ -112,13 +161,24 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
|||||||
const nav = navigationRef.current
|
const nav = navigationRef.current
|
||||||
if (!nav) return
|
if (!nav) return
|
||||||
|
|
||||||
|
if (LOGBOOK_TOUR_STEPS.has(stepId)) {
|
||||||
|
nav.setProfileOpen(false)
|
||||||
|
nav.setLogbookActive(true)
|
||||||
|
}
|
||||||
|
|
||||||
if (stepId === 'nav_logs' || stepId === 'entry_list' || stepId === 'entry_open' || stepId === 'entry_track') {
|
if (stepId === 'nav_logs' || stepId === 'entry_list' || stepId === 'entry_open' || stepId === 'entry_track') {
|
||||||
nav.setActiveTab('logs')
|
nav.setActiveTab('logs')
|
||||||
}
|
}
|
||||||
if (stepId === 'entry_open' || stepId === 'entry_track') {
|
|
||||||
|
if (stepId === 'entry_list' || stepId === 'entry_open') {
|
||||||
|
nav.setSelectedEntryId(null)
|
||||||
|
} else if (tourStepOpensEntry(stepId)) {
|
||||||
const firstEntryId = resolveFirstEntryId()
|
const firstEntryId = resolveFirstEntryId()
|
||||||
if (firstEntryId) nav.setSelectedEntryId(firstEntryId)
|
if (firstEntryId) nav.setSelectedEntryId(firstEntryId)
|
||||||
|
} else if (LOGBOOK_TOUR_STEPS.has(stepId)) {
|
||||||
|
nav.setSelectedEntryId(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stepId === 'nav_vessel') {
|
if (stepId === 'nav_vessel') {
|
||||||
nav.setSelectedEntryId(null)
|
nav.setSelectedEntryId(null)
|
||||||
nav.setActiveTab('vessel')
|
nav.setActiveTab('vessel')
|
||||||
@@ -137,19 +197,34 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
|||||||
} else {
|
} else {
|
||||||
nav.setFeedbackOpen(false)
|
nav.setFeedbackOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (stepId === 'nav_profile') {
|
||||||
|
nav.setProfileOpen(false)
|
||||||
|
nav.setLogbookActive(false)
|
||||||
|
}
|
||||||
|
if (stepId === 'profile_preferences') {
|
||||||
|
nav.setLogbookActive(false)
|
||||||
|
nav.setProfileOpen(true)
|
||||||
|
}
|
||||||
}, [resolveFirstEntryId])
|
}, [resolveFirstEntryId])
|
||||||
|
|
||||||
const scrollToCurrentTarget = useCallback((stepId: TourStepId | null) => {
|
const scrollToCurrentTarget = useCallback((stepId: TourStepId | null) => {
|
||||||
if (!stepId) return
|
if (!stepId) return
|
||||||
const selector = TARGET_BY_STEP[stepId]
|
const selector = TARGET_BY_STEP[stepId]
|
||||||
if (!selector) return
|
if (!selector) return
|
||||||
const delayMs = stepId === 'nav_feedback' ? 180 : 0
|
|
||||||
window.setTimeout(() => {
|
for (const delayMs of getTourScrollRetryDelays(stepId)) {
|
||||||
window.requestAnimationFrame(() => {
|
window.setTimeout(() => {
|
||||||
const el = document.querySelector(selector)
|
window.requestAnimationFrame(() => {
|
||||||
el?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' })
|
const el = document.querySelector(selector)
|
||||||
})
|
el?.scrollIntoView({
|
||||||
}, delayMs)
|
behavior: stepId === 'entry_track' ? 'instant' : 'smooth',
|
||||||
|
block: 'center',
|
||||||
|
inline: 'nearest'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, delayMs)
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const startTour = useCallback((options?: { force?: boolean; demoMode?: boolean }) => {
|
const startTour = useCallback((options?: { force?: boolean; demoMode?: boolean }) => {
|
||||||
@@ -173,6 +248,8 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
|||||||
trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_COMPLETED, tourProps)
|
trackPlausibleEvent(PlausibleEvents.ONBOARDING_TOUR_COMPLETED, tourProps)
|
||||||
const nav = navigationRef.current
|
const nav = navigationRef.current
|
||||||
if (nav && !tourModeRef.current.demoMode) {
|
if (nav && !tourModeRef.current.demoMode) {
|
||||||
|
nav.setProfileOpen(false)
|
||||||
|
nav.setLogbookActive(true)
|
||||||
nav.setSelectedEntryId(null)
|
nav.setSelectedEntryId(null)
|
||||||
nav.setActiveTab('stats')
|
nav.setActiveTab('stats')
|
||||||
}
|
}
|
||||||
@@ -183,6 +260,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
tourModeRef.current = { demoMode: false }
|
tourModeRef.current = { demoMode: false }
|
||||||
navigationRef.current?.setFeedbackOpen(false)
|
navigationRef.current?.setFeedbackOpen(false)
|
||||||
|
navigationRef.current?.setProfileOpen(false)
|
||||||
setIsDemoTour(false)
|
setIsDemoTour(false)
|
||||||
setIsActive(false)
|
setIsActive(false)
|
||||||
setStepIndex(0)
|
setStepIndex(0)
|
||||||
@@ -213,8 +291,25 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
|||||||
if (!isActive) return
|
if (!isActive) return
|
||||||
const stepId = getStepOrder(isDemoTour)[stepIndex]
|
const stepId = getStepOrder(isDemoTour)[stepIndex]
|
||||||
if (!stepId) return
|
if (!stepId) return
|
||||||
applyStepSideEffects(stepId)
|
|
||||||
scrollToCurrentTarget(stepId)
|
let cancelled = false
|
||||||
|
const run = async () => {
|
||||||
|
if (LOGBOOK_TOUR_STEPS.has(stepId) && !isDemoTour) {
|
||||||
|
await navigationRef.current?.ensureLogbookForTour?.()
|
||||||
|
}
|
||||||
|
if (cancelled) return
|
||||||
|
applyStepSideEffects(stepId)
|
||||||
|
scrollToCurrentTarget(stepId)
|
||||||
|
setLayoutTick((tick) => tick + 1)
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (!cancelled) setLayoutTick((tick) => tick + 1)
|
||||||
|
}, 150)
|
||||||
|
}
|
||||||
|
void run()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
}, [isActive, isDemoTour, stepIndex, applyStepSideEffects, scrollToCurrentTarget])
|
}, [isActive, isDemoTour, stepIndex, applyStepSideEffects, scrollToCurrentTarget])
|
||||||
|
|
||||||
const restartTour = useCallback(() => {
|
const restartTour = useCallback(() => {
|
||||||
@@ -257,6 +352,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
|||||||
currentStepId,
|
currentStepId,
|
||||||
currentStepIndex: stepIndex,
|
currentStepIndex: stepIndex,
|
||||||
totalSteps: stepOrder.length,
|
totalSteps: stepOrder.length,
|
||||||
|
layoutTick,
|
||||||
startTour,
|
startTour,
|
||||||
stopTour,
|
stopTour,
|
||||||
restartTour,
|
restartTour,
|
||||||
@@ -281,6 +377,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
|||||||
startTour,
|
startTour,
|
||||||
stepIndex,
|
stepIndex,
|
||||||
stepOrder.length,
|
stepOrder.length,
|
||||||
|
layoutTick,
|
||||||
stopTour
|
stopTour
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -321,3 +418,10 @@ export function getTourTargetSelector(stepId: TourStepId | null): string | null
|
|||||||
export function isCenteredTourStep(stepId: TourStepId | null): boolean {
|
export function isCenteredTourStep(stepId: TourStepId | null): boolean {
|
||||||
return stepId === 'welcome' || stepId === 'finish'
|
return stepId === 'welcome' || stepId === 'finish'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getTourTargetRetryDelay(stepId: TourStepId | null): number {
|
||||||
|
if (stepId === 'entry_track') return 400
|
||||||
|
if (stepId === 'profile_preferences') return 300
|
||||||
|
if (stepId === 'nav_profile') return 200
|
||||||
|
return 120
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { useRegisterSW } from 'virtual:pwa-register/react'
|
import { useRegisterSW } from 'virtual:pwa-register/react'
|
||||||
|
import {
|
||||||
|
forcePwaRecovery,
|
||||||
|
markReloadAttempt,
|
||||||
|
recentlyAttemptedReload,
|
||||||
|
triggerServiceWorkerUpdate
|
||||||
|
} from '../services/pwaStartup.js'
|
||||||
|
import { isDeployedVersionNewer } from '../services/pwaVersion.js'
|
||||||
|
|
||||||
const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000
|
const UPDATE_CHECK_INTERVAL_MS = 15 * 60 * 1000
|
||||||
const UPDATE_SUPPRESS_KEY = 'pwa_update_suppress_until'
|
const UPDATE_SUPPRESS_KEY = 'pwa_update_suppress_until'
|
||||||
const UPDATE_SUPPRESS_MS = 30_000
|
const UPDATE_SUPPRESS_MS = 30_000
|
||||||
const UPDATE_DISMISS_SUPPRESS_MS = 60 * 60 * 1000
|
const UPDATE_DISMISS_SUPPRESS_MS = 15 * 60 * 1000
|
||||||
const UPDATE_RELOAD_FALLBACK_MS = 2000
|
const UPDATE_RELOAD_FALLBACK_MS = 2_000
|
||||||
/** Prevent Android PWA cold-start reload loops from onNeedReload. */
|
const UPDATE_HARD_RECOVERY_MS = 5_000
|
||||||
const PWA_INITIAL_RELOAD_KEY = 'pwa_sw_initial_reload_done'
|
|
||||||
|
|
||||||
function isUpdateSuppressed(): boolean {
|
function isUpdateSuppressed(): boolean {
|
||||||
const suppressUntil = Number(sessionStorage.getItem(UPDATE_SUPPRESS_KEY) || '0')
|
const suppressUntil = Number(sessionStorage.getItem(UPDATE_SUPPRESS_KEY) || '0')
|
||||||
@@ -22,10 +28,16 @@ function clearUpdateSuppression(): void {
|
|||||||
sessionStorage.removeItem(UPDATE_SUPPRESS_KEY)
|
sessionStorage.removeItem(UPDATE_SUPPRESS_KEY)
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleUpdateChecks(registration: ServiceWorkerRegistration): () => void {
|
function scheduleUpdateChecks(
|
||||||
|
registration: ServiceWorkerRegistration,
|
||||||
|
onOutdated: () => void
|
||||||
|
): () => void {
|
||||||
const checkForUpdate = () => {
|
const checkForUpdate = () => {
|
||||||
if (isUpdateSuppressed()) return
|
if (isUpdateSuppressed()) return
|
||||||
registration.update().catch(() => {})
|
registration.update().catch(() => {})
|
||||||
|
void isDeployedVersionNewer().then((outdated) => {
|
||||||
|
if (outdated) onOutdated()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const onVisibilityChange = () => {
|
const onVisibilityChange = () => {
|
||||||
@@ -34,17 +46,44 @@ function scheduleUpdateChecks(registration: ServiceWorkerRegistration): () => vo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onOnline = () => {
|
||||||
|
checkForUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||||
const intervalId = window.setInterval(checkForUpdate, UPDATE_CHECK_INTERVAL_MS)
|
window.addEventListener('online', onOnline)
|
||||||
|
const updateIntervalId = window.setInterval(checkForUpdate, UPDATE_CHECK_INTERVAL_MS)
|
||||||
|
|
||||||
|
checkForUpdate()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||||
window.clearInterval(intervalId)
|
window.removeEventListener('online', onOnline)
|
||||||
|
window.clearInterval(updateIntervalId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reloadForServiceWorkerTakeover(): void {
|
||||||
|
if (recentlyAttemptedReload()) return
|
||||||
|
markReloadAttempt()
|
||||||
|
clearUpdateSuppression()
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
export function usePwaUpdate() {
|
export function usePwaUpdate() {
|
||||||
const cleanupRef = useRef<(() => void) | null>(null)
|
const cleanupRef = useRef<(() => void) | null>(null)
|
||||||
|
const reloadFallbackTimerRef = useRef<number | null>(null)
|
||||||
|
const forceRecoveryTimerRef = useRef<number | null>(null)
|
||||||
|
const setNeedRefreshRef = useRef<((value: boolean) => void) | null>(null)
|
||||||
|
const pendingNeedRefreshRef = useRef<boolean | null>(null)
|
||||||
|
|
||||||
|
const applyNeedRefresh = (value: boolean) => {
|
||||||
|
if (setNeedRefreshRef.current) {
|
||||||
|
setNeedRefreshRef.current(value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingNeedRefreshRef.current = value
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
needRefresh: [needRefresh, setNeedRefresh],
|
needRefresh: [needRefresh, setNeedRefresh],
|
||||||
@@ -52,39 +91,56 @@ export function usePwaUpdate() {
|
|||||||
} = useRegisterSW({
|
} = useRegisterSW({
|
||||||
immediate: !import.meta.env.DEV,
|
immediate: !import.meta.env.DEV,
|
||||||
onNeedReload() {
|
onNeedReload() {
|
||||||
// First SW takeover requires one reload; guard against repeated reloads on Android PWA resume.
|
if (isUpdateSuppressed()) return
|
||||||
if (sessionStorage.getItem(PWA_INITIAL_RELOAD_KEY)) {
|
applyNeedRefresh(true)
|
||||||
return
|
|
||||||
}
|
|
||||||
sessionStorage.setItem(PWA_INITIAL_RELOAD_KEY, '1')
|
|
||||||
clearUpdateSuppression()
|
|
||||||
setNeedRefresh(false)
|
|
||||||
window.location.reload()
|
|
||||||
},
|
},
|
||||||
onNeedRefresh() {
|
onNeedRefresh() {
|
||||||
if (isUpdateSuppressed()) return
|
if (isUpdateSuppressed()) return
|
||||||
setNeedRefresh(true)
|
applyNeedRefresh(true)
|
||||||
},
|
},
|
||||||
onRegisteredSW(_swUrl: string, registration: ServiceWorkerRegistration | undefined) {
|
onRegisteredSW(_swUrl: string, registration: ServiceWorkerRegistration | undefined) {
|
||||||
if (!registration) return
|
if (!registration) return
|
||||||
|
|
||||||
if (isUpdateSuppressed() || !registration.waiting) {
|
if (isUpdateSuppressed() || !registration.waiting) {
|
||||||
setNeedRefresh(false)
|
applyNeedRefresh(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanupRef.current?.()
|
cleanupRef.current?.()
|
||||||
cleanupRef.current = scheduleUpdateChecks(registration)
|
cleanupRef.current = scheduleUpdateChecks(registration, () => {
|
||||||
|
if (isUpdateSuppressed()) return
|
||||||
|
applyNeedRefresh(true)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
setNeedRefreshRef.current = setNeedRefresh
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isUpdateSuppressed()) {
|
if (isUpdateSuppressed()) {
|
||||||
setNeedRefresh(false)
|
setNeedRefresh(false)
|
||||||
|
} else if (pendingNeedRefreshRef.current !== null) {
|
||||||
|
const pending = pendingNeedRefreshRef.current
|
||||||
|
pendingNeedRefreshRef.current = null
|
||||||
|
setNeedRefresh(pending)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void isDeployedVersionNewer().then((outdated) => {
|
||||||
|
if (outdated) {
|
||||||
|
setNeedRefresh(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cleanupRef.current?.()
|
cleanupRef.current?.()
|
||||||
cleanupRef.current = null
|
cleanupRef.current = null
|
||||||
|
if (reloadFallbackTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(reloadFallbackTimerRef.current)
|
||||||
|
reloadFallbackTimerRef.current = null
|
||||||
|
}
|
||||||
|
if (forceRecoveryTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(forceRecoveryTimerRef.current)
|
||||||
|
forceRecoveryTimerRef.current = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [setNeedRefresh])
|
}, [setNeedRefresh])
|
||||||
|
|
||||||
@@ -93,11 +149,24 @@ export function usePwaUpdate() {
|
|||||||
suppressUpdatePrompt()
|
suppressUpdatePrompt()
|
||||||
|
|
||||||
await updateServiceWorker(true)
|
await updateServiceWorker(true)
|
||||||
|
await triggerServiceWorkerUpdate()
|
||||||
|
|
||||||
// vite-plugin-pwa reloads via the "controlling" event; fallback if that does not fire.
|
if (reloadFallbackTimerRef.current !== null) {
|
||||||
window.setTimeout(() => {
|
window.clearTimeout(reloadFallbackTimerRef.current)
|
||||||
window.location.reload()
|
}
|
||||||
|
if (forceRecoveryTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(forceRecoveryTimerRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
reloadFallbackTimerRef.current = window.setTimeout(() => {
|
||||||
|
reloadFallbackTimerRef.current = null
|
||||||
|
reloadForServiceWorkerTakeover()
|
||||||
}, UPDATE_RELOAD_FALLBACK_MS)
|
}, UPDATE_RELOAD_FALLBACK_MS)
|
||||||
|
|
||||||
|
forceRecoveryTimerRef.current = window.setTimeout(() => {
|
||||||
|
forceRecoveryTimerRef.current = null
|
||||||
|
void forcePwaRecovery()
|
||||||
|
}, UPDATE_HARD_RECOVERY_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
const dismissUpdate = () => {
|
const dismissUpdate = () => {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
},
|
},
|
||||||
@@ -116,7 +117,13 @@
|
|||||||
"no_sails": "Keine Segel hinterlegt.",
|
"no_sails": "Keine Segel hinterlegt.",
|
||||||
"photo_add": "Foto hinzufügen",
|
"photo_add": "Foto hinzufügen",
|
||||||
"photo_change": "Foto ändern",
|
"photo_change": "Foto ändern",
|
||||||
"photo_delete": "Foto löschen"
|
"photo_delete": "Foto löschen",
|
||||||
|
"tanks_section": "Tanks (Fassungsvermögen)",
|
||||||
|
"tanks_help": "Optional in Liter — ermöglicht Slider im Journal bei bekannten Tankgrößen.",
|
||||||
|
"freshwater_capacity_l": "Trinkwasser (Liter)",
|
||||||
|
"fuel_capacity_l": "Treibstoff (Liter)",
|
||||||
|
"greywater_capacity_l": "Grauwasser (Liter)",
|
||||||
|
"invalid_tank_liters": "Ungültiger Zahlenwert — bitte Liter als Zahl eingeben (z. B. 200)."
|
||||||
},
|
},
|
||||||
"logs": {
|
"logs": {
|
||||||
"title": "Logbuch-Journal",
|
"title": "Logbuch-Journal",
|
||||||
@@ -137,6 +144,10 @@
|
|||||||
"route": "Reise von/nach",
|
"route": "Reise von/nach",
|
||||||
"freshwater": "Frischwasser (Liter)",
|
"freshwater": "Frischwasser (Liter)",
|
||||||
"fuel": "Treibstoff / Fuel (Liter)",
|
"fuel": "Treibstoff / Fuel (Liter)",
|
||||||
|
"greywater": "Grauwasser (Liter)",
|
||||||
|
"greywater_level": "Füllstand",
|
||||||
|
"tank_slider_of_max": "{{current}} / {{max}} L",
|
||||||
|
"tank_capacity_tooltip": "Wenn in den Schiffsdaten die Tank-Fassungsvermögen (Liter) hinterlegt sind, kannst du Füllstände hier per Slider eingeben.",
|
||||||
"morning": "Stand morgens",
|
"morning": "Stand morgens",
|
||||||
"refilled": "Nachgefüllt",
|
"refilled": "Nachgefüllt",
|
||||||
"evening": "Stand abends",
|
"evening": "Stand abends",
|
||||||
@@ -182,7 +193,7 @@
|
|||||||
"delete_entry": "Tag löschen",
|
"delete_entry": "Tag löschen",
|
||||||
"delete_confirm": "Bist du sicher, dass du diesen Reisetag unwiderruflich löschen möchtest?",
|
"delete_confirm": "Bist du sicher, dass du diesen Reisetag unwiderruflich löschen möchtest?",
|
||||||
"carry_over_tanks_title": "Daten vom Vortag übernehmen?",
|
"carry_over_tanks_title": "Daten vom Vortag übernehmen?",
|
||||||
"carry_over_tanks_confirm": "Start-Hafen, Frischwasser- und Kraftstoff-Morgenstände vom letzten Reisetag übernehmen?\n\nStart-Hafen: {{departure}}\nFrischwasser: {{fw}} L\nKraftstoff: {{fuel}} L",
|
"carry_over_tanks_confirm": "Start-Hafen, Frischwasser-, Kraftstoff- und Grauwasser-Startstände vom letzten Reisetag übernehmen?\n\nStart-Hafen: {{departure}}\nFrischwasser: {{fw}} L\nKraftstoff: {{fuel}} L\nGrauwasser: {{greywater}} L",
|
||||||
"carry_over_tanks_yes": "Übernehmen",
|
"carry_over_tanks_yes": "Übernehmen",
|
||||||
"carry_over_tanks_no": "Mit 0 starten",
|
"carry_over_tanks_no": "Mit 0 starten",
|
||||||
"event_title": "Chronologisches Ereignisprotokoll",
|
"event_title": "Chronologisches Ereignisprotokoll",
|
||||||
@@ -222,6 +233,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)",
|
||||||
@@ -291,7 +304,23 @@
|
|||||||
"edit_title": "Logbuch umbenennen",
|
"edit_title": "Logbuch umbenennen",
|
||||||
"edit_placeholder": "Neuer Name des Logbuchs",
|
"edit_placeholder": "Neuer Name des Logbuchs",
|
||||||
"edit_success": "Logbuch erfolgreich umbenannt",
|
"edit_success": "Logbuch erfolgreich umbenannt",
|
||||||
"edit_btn": "Umbenennen"
|
"edit_btn": "Umbenennen",
|
||||||
|
"filter_label": "Logbücher filtern",
|
||||||
|
"filter_placeholder": "Name, Jahr oder Datum …",
|
||||||
|
"filter_clear": "Filter zurücksetzen",
|
||||||
|
"filter_results": "{{count}} Treffer",
|
||||||
|
"filter_no_results": "Keine Logbücher passen zu deiner Suche. Probiere einen anderen Namen oder ein anderes Jahr.",
|
||||||
|
"sort_label": "Sortieren",
|
||||||
|
"sort_by_label": "Sortieren nach",
|
||||||
|
"sort_by_name": "Name",
|
||||||
|
"sort_by_date": "Datum",
|
||||||
|
"sort_dir_label": "Reihenfolge",
|
||||||
|
"sort_asc": "Aufsteigend",
|
||||||
|
"sort_desc": "Absteigend",
|
||||||
|
"sort_name_asc": "Name A bis Z",
|
||||||
|
"sort_name_desc": "Name Z bis A",
|
||||||
|
"sort_date_asc": "Älteste zuerst",
|
||||||
|
"sort_date_desc": "Neueste zuerst"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"title": "Benutzerprofil",
|
"title": "Benutzerprofil",
|
||||||
@@ -380,7 +409,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 +474,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 +498,12 @@
|
|||||||
"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",
|
"invite_push_prompt_title": "Push-Benachrichtigungen aktivieren?",
|
||||||
"tour_desc": "Lass dich erneut durch die wichtigsten Bereiche der App führen.",
|
"invite_push_prompt_message": "Sobald eingeladene Crewmitglieder Änderungen synchronisieren, kannst du per Push informiert werden. Es werden keine Logbuch-Inhalte im Klartext gesendet.",
|
||||||
"tour_restart": "Tour erneut starten",
|
"invite_push_prompt_ios_message": "Sobald Crewmitglieder Änderungen synchronisieren, kannst du per Push informiert werden. Auf dem iPhone/iPad: App zum Home-Bildschirm hinzufügen (iOS 16.4+), dann Push im Benutzerprofil aktivieren.",
|
||||||
"push_title": "Push-Benachrichtigungen",
|
"invite_push_prompt_enable": "Jetzt aktivieren",
|
||||||
"push_desc": "Als Logbuch-Eigner wirst du benachrichtigt, wenn eingeladene Crewmitglieder Änderungen synchronisieren. Es werden keine Inhalte im Klartext übermittelt.",
|
"invite_push_prompt_later": "Später",
|
||||||
"push_enable": "Bei Crew-Änderungen benachrichtigen",
|
"invite_push_prompt_success": "Push-Benachrichtigungen sind auf diesem Gerät aktiv.",
|
||||||
"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",
|
||||||
@@ -668,9 +704,17 @@
|
|||||||
"title": "Feedback senden",
|
"title": "Feedback senden",
|
||||||
"body": "Über dieses Formular kannst du Fehler, Ideen oder allgemeines Feedback direkt an das Projektteam schicken – auch nach der Tour jederzeit über das Symbol oben rechts."
|
"body": "Über dieses Formular kannst du Fehler, Ideen oder allgemeines Feedback direkt an das Projektteam schicken – auch nach der Tour jederzeit über das Symbol oben rechts."
|
||||||
},
|
},
|
||||||
|
"nav_profile": {
|
||||||
|
"title": "Dein Benutzerprofil",
|
||||||
|
"body": "Über den Skipper-Button oben erreichst du dein persönliches Profil – unabhängig vom aktuellen Logbuch."
|
||||||
|
},
|
||||||
|
"profile_preferences": {
|
||||||
|
"title": "Konto & Darstellung",
|
||||||
|
"body": "Hier verwaltest du deine Konto-Identität, Theme und Hell/Dunkel-Modus. Die App-Tour kannst du jederzeit erneut starten. Passkeys und Sicherheitseinstellungen findest du weiter unten im Profil."
|
||||||
|
},
|
||||||
"finish": {
|
"finish": {
|
||||||
"title": "Alles klar!",
|
"title": "Alles klar!",
|
||||||
"body": "Du landest gleich im Statistik-Dashboard. Die Tour kannst du jederzeit unter Einstellungen erneut starten. Gute Fahrt!"
|
"body": "Du landest gleich im Statistik-Dashboard. Die Tour kannst du jederzeit im Benutzerprofil erneut starten. Gute Fahrt!"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
@@ -116,7 +117,13 @@
|
|||||||
"no_sails": "No sails defined.",
|
"no_sails": "No sails defined.",
|
||||||
"photo_add": "Add Photo",
|
"photo_add": "Add Photo",
|
||||||
"photo_change": "Change Photo",
|
"photo_change": "Change Photo",
|
||||||
"photo_delete": "Delete Photo"
|
"photo_delete": "Delete Photo",
|
||||||
|
"tanks_section": "Tanks (capacity)",
|
||||||
|
"tanks_help": "Optional, in liters — enables sliders in the journal when tank sizes are known.",
|
||||||
|
"freshwater_capacity_l": "Freshwater (liters)",
|
||||||
|
"fuel_capacity_l": "Fuel (liters)",
|
||||||
|
"greywater_capacity_l": "Greywater (liters)",
|
||||||
|
"invalid_tank_liters": "Invalid number — please enter capacity in liters (e.g. 200)."
|
||||||
},
|
},
|
||||||
"logs": {
|
"logs": {
|
||||||
"title": "Logbook Journal",
|
"title": "Logbook Journal",
|
||||||
@@ -137,6 +144,10 @@
|
|||||||
"route": "Route / Journey",
|
"route": "Route / Journey",
|
||||||
"freshwater": "Freshwater (Liters)",
|
"freshwater": "Freshwater (Liters)",
|
||||||
"fuel": "Fuel (Liters)",
|
"fuel": "Fuel (Liters)",
|
||||||
|
"greywater": "Greywater (Liters)",
|
||||||
|
"greywater_level": "Fill level",
|
||||||
|
"tank_slider_of_max": "{{current}} / {{max}} L",
|
||||||
|
"tank_capacity_tooltip": "If tank capacities (liters) are set in vessel master data, you can enter fill levels here using sliders.",
|
||||||
"morning": "Morning Level",
|
"morning": "Morning Level",
|
||||||
"refilled": "Refilled",
|
"refilled": "Refilled",
|
||||||
"evening": "Evening Level",
|
"evening": "Evening Level",
|
||||||
@@ -182,7 +193,7 @@
|
|||||||
"delete_entry": "Delete Day",
|
"delete_entry": "Delete Day",
|
||||||
"delete_confirm": "Are you sure you want to permanently delete this travel day?",
|
"delete_confirm": "Are you sure you want to permanently delete this travel day?",
|
||||||
"carry_over_tanks_title": "Carry over from previous day?",
|
"carry_over_tanks_title": "Carry over from previous day?",
|
||||||
"carry_over_tanks_confirm": "Use the previous travel day's destination as departure port and closing tank levels as morning levels?\n\nDeparture port: {{departure}}\nFreshwater: {{fw}} L\nFuel: {{fuel}} L",
|
"carry_over_tanks_confirm": "Use the previous travel day's destination as departure port and closing tank levels as morning levels?\n\nDeparture port: {{departure}}\nFreshwater: {{fw}} L\nFuel: {{fuel}} L\nGreywater: {{greywater}} L",
|
||||||
"carry_over_tanks_yes": "Carry over",
|
"carry_over_tanks_yes": "Carry over",
|
||||||
"carry_over_tanks_no": "Start at 0",
|
"carry_over_tanks_no": "Start at 0",
|
||||||
"event_title": "Chronological Event Logbook",
|
"event_title": "Chronological Event Logbook",
|
||||||
@@ -222,6 +233,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)",
|
||||||
@@ -291,7 +304,23 @@
|
|||||||
"edit_title": "Rename Logbook",
|
"edit_title": "Rename Logbook",
|
||||||
"edit_placeholder": "New name of the logbook",
|
"edit_placeholder": "New name of the logbook",
|
||||||
"edit_success": "Logbook renamed successfully",
|
"edit_success": "Logbook renamed successfully",
|
||||||
"edit_btn": "Rename"
|
"edit_btn": "Rename",
|
||||||
|
"filter_label": "Filter logbooks",
|
||||||
|
"filter_placeholder": "Name, year or date …",
|
||||||
|
"filter_clear": "Clear filter",
|
||||||
|
"filter_results": "{{count}} matches",
|
||||||
|
"filter_no_results": "No logbooks match your search. Try a different name or year.",
|
||||||
|
"sort_label": "Sort",
|
||||||
|
"sort_by_label": "Sort by",
|
||||||
|
"sort_by_name": "Name",
|
||||||
|
"sort_by_date": "Date",
|
||||||
|
"sort_dir_label": "Order",
|
||||||
|
"sort_asc": "Ascending",
|
||||||
|
"sort_desc": "Descending",
|
||||||
|
"sort_name_asc": "Name A to Z",
|
||||||
|
"sort_name_desc": "Name Z to A",
|
||||||
|
"sort_date_asc": "Oldest first",
|
||||||
|
"sort_date_desc": "Newest first"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"title": "User profile",
|
"title": "User profile",
|
||||||
@@ -380,7 +409,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 +474,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 +498,12 @@
|
|||||||
"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",
|
"invite_push_prompt_title": "Enable push notifications?",
|
||||||
"tour_desc": "Take a guided walkthrough of the main areas of the app again.",
|
"invite_push_prompt_message": "When invited crew members sync changes, you can be notified via push. No logbook content is sent in plain text.",
|
||||||
"tour_restart": "Restart tour",
|
"invite_push_prompt_ios_message": "When crew members sync changes, you can get push notifications. On iPhone/iPad: add the app to your Home Screen (iOS 16.4+), then enable push in your user profile.",
|
||||||
"push_title": "Push notifications",
|
"invite_push_prompt_enable": "Enable now",
|
||||||
"push_desc": "As logbook owner you are notified when invited crew members sync changes. No logbook content is sent in plain text.",
|
"invite_push_prompt_later": "Later",
|
||||||
"push_enable": "Notify on crew changes",
|
"invite_push_prompt_success": "Push notifications are active on this device.",
|
||||||
"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",
|
||||||
@@ -668,9 +704,17 @@
|
|||||||
"title": "Send feedback",
|
"title": "Send feedback",
|
||||||
"body": "Use this form to report bugs, ideas, or general feedback to the project team — you can also open it anytime later via the icon in the top right."
|
"body": "Use this form to report bugs, ideas, or general feedback to the project team — you can also open it anytime later via the icon in the top right."
|
||||||
},
|
},
|
||||||
|
"nav_profile": {
|
||||||
|
"title": "Your user profile",
|
||||||
|
"body": "Tap the skipper button at the top to open your personal profile — independent of the current logbook."
|
||||||
|
},
|
||||||
|
"profile_preferences": {
|
||||||
|
"title": "Account & appearance",
|
||||||
|
"body": "Manage your account identity, theme, and light/dark mode here. You can restart the app tour anytime. Passkeys and security settings are further down on the profile page."
|
||||||
|
},
|
||||||
"finish": {
|
"finish": {
|
||||||
"title": "You're all set!",
|
"title": "You're all set!",
|
||||||
"body": "You'll land on the statistics dashboard next. You can restart the tour anytime in Settings. Fair winds!"
|
"body": "You'll land on the statistics dashboard next. You can restart the tour anytime from your user profile. Fair winds!"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+11
-102
@@ -1,64 +1,8 @@
|
|||||||
:root {
|
/* Minimal app shell — component styles live in App.css / themes.css */
|
||||||
--text: #6b6375;
|
|
||||||
--text-h: #08060d;
|
|
||||||
--bg: #fff;
|
|
||||||
--border: #e5e4e7;
|
|
||||||
--code-bg: #f4f3ec;
|
|
||||||
--accent: #aa3bff;
|
|
||||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
|
||||||
--accent-border: rgba(170, 59, 255, 0.5);
|
|
||||||
--social-bg: rgba(244, 243, 236, 0.5);
|
|
||||||
--shadow:
|
|
||||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
|
||||||
|
|
||||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
*,
|
||||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
*::before,
|
||||||
--mono: ui-monospace, Consolas, monospace;
|
*::after {
|
||||||
|
|
||||||
font: 18px/145% var(--sans);
|
|
||||||
letter-spacing: 0.18px;
|
|
||||||
color-scheme: light dark;
|
|
||||||
color: var(--text);
|
|
||||||
background: var(--bg);
|
|
||||||
font-synthesis: none;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--text: #9ca3af;
|
|
||||||
--text-h: #f3f4f6;
|
|
||||||
--bg: #16171d;
|
|
||||||
--border: #2e303a;
|
|
||||||
--code-bg: #1f2028;
|
|
||||||
--accent: #c084fc;
|
|
||||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
|
||||||
--accent-border: rgba(192, 132, 252, 0.5);
|
|
||||||
--social-bg: rgba(47, 48, 58, 0.5);
|
|
||||||
--shadow:
|
|
||||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#social .button-icon {
|
|
||||||
filter: invert(1) brightness(2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#root {
|
|
||||||
width: 1126px;
|
|
||||||
max-width: 100%;
|
|
||||||
margin: 0 auto;
|
|
||||||
text-align: center;
|
|
||||||
border-inline: 1px solid var(--border);
|
|
||||||
min-height: 100svh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,46 +10,11 @@ body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
#root {
|
||||||
h2 {
|
width: 100%;
|
||||||
font-family: var(--heading);
|
max-width: 100%;
|
||||||
font-weight: 500;
|
min-height: 100svh;
|
||||||
color: var(--text-h);
|
display: flex;
|
||||||
}
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
h1 {
|
|
||||||
font-size: 56px;
|
|
||||||
letter-spacing: -1.68px;
|
|
||||||
margin: 32px 0;
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
font-size: 36px;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
h2 {
|
|
||||||
font-size: 24px;
|
|
||||||
line-height: 118%;
|
|
||||||
letter-spacing: -0.24px;
|
|
||||||
margin: 0 0 8px;
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
code,
|
|
||||||
.counter {
|
|
||||||
font-family: var(--mono);
|
|
||||||
display: inline-flex;
|
|
||||||
border-radius: 4px;
|
|
||||||
color: var(--text-h);
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 135%;
|
|
||||||
padding: 4px 8px;
|
|
||||||
background: var(--code-bg);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,15 @@ import { createRoot } from 'react-dom/client'
|
|||||||
import 'leaflet/dist/leaflet.css'
|
import 'leaflet/dist/leaflet.css'
|
||||||
import './themes.css'
|
import './themes.css'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
import './App.css'
|
||||||
import './i18n'
|
import './i18n'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
import { applyAppearanceToDocument } from './services/appearance.ts'
|
import { applyAppearanceToDocument } from './services/appearance.ts'
|
||||||
|
import {
|
||||||
|
installStaleAssetRecovery,
|
||||||
|
markReloadAttempt,
|
||||||
|
reconcileVersionOnStartup
|
||||||
|
} from './services/pwaStartup.ts'
|
||||||
|
|
||||||
/** Stale PWA precache on localhost can shadow Vite dev modules. */
|
/** Stale PWA precache on localhost can shadow Vite dev modules. */
|
||||||
async function clearDevServiceWorkerCaches(): Promise<void> {
|
async function clearDevServiceWorkerCaches(): Promise<void> {
|
||||||
@@ -35,8 +41,19 @@ function renderBootstrapError(message: string): void {
|
|||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
applyAppearanceToDocument()
|
applyAppearanceToDocument()
|
||||||
|
installStaleAssetRecovery()
|
||||||
await clearDevServiceWorkerCaches()
|
await clearDevServiceWorkerCaches()
|
||||||
|
|
||||||
|
const startupResult = await reconcileVersionOnStartup()
|
||||||
|
if (startupResult === 'reload') {
|
||||||
|
markReloadAttempt()
|
||||||
|
window.location.reload()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (startupResult === 'recovered') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const rootEl = document.getElementById('root')
|
const rootEl = document.getElementById('root')
|
||||||
if (!rootEl) {
|
if (!rootEl) {
|
||||||
throw new Error('Missing #root element')
|
throw new Error('Missing #root element')
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
fetchAppearancePrefs,
|
||||||
|
saveAppearancePrefsToServer,
|
||||||
|
syncAppearancePrefs
|
||||||
|
} from './appearancePrefs.js'
|
||||||
|
import { setThemePreference } from './userPreferences.js'
|
||||||
|
|
||||||
|
const USER_ID = 'appearance-sync-user'
|
||||||
|
|
||||||
|
vi.mock('./api.js', () => ({
|
||||||
|
apiJson: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { apiJson } from './api.js'
|
||||||
|
|
||||||
|
const mockedApiJson = vi.mocked(apiJson)
|
||||||
|
|
||||||
|
describe('appearancePrefs', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fetchAppearancePrefs returns defaults when not authenticated', async () => {
|
||||||
|
await expect(fetchAppearancePrefs()).resolves.toEqual({
|
||||||
|
theme: 'auto',
|
||||||
|
colorScheme: 'auto',
|
||||||
|
persisted: false
|
||||||
|
})
|
||||||
|
expect(mockedApiJson).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('syncAppearancePrefs applies server prefs after cache wipe', async () => {
|
||||||
|
localStorage.setItem('active_userid', USER_ID)
|
||||||
|
mockedApiJson.mockResolvedValueOnce({
|
||||||
|
theme: 'ocean',
|
||||||
|
colorScheme: 'dark',
|
||||||
|
persisted: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const changed = vi.fn()
|
||||||
|
window.addEventListener('appearance-changed', changed)
|
||||||
|
|
||||||
|
await syncAppearancePrefs(USER_ID)
|
||||||
|
|
||||||
|
expect(localStorage.getItem(`user_pref_theme_${USER_ID}`)).toBe('ocean')
|
||||||
|
expect(localStorage.getItem(`user_pref_color_scheme_${USER_ID}`)).toBe('dark')
|
||||||
|
expect(changed).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('syncAppearancePrefs uploads local prefs when server has none', async () => {
|
||||||
|
localStorage.setItem('active_userid', USER_ID)
|
||||||
|
setThemePreference(USER_ID, 'material')
|
||||||
|
mockedApiJson
|
||||||
|
.mockResolvedValueOnce({ theme: 'auto', colorScheme: 'auto', persisted: false })
|
||||||
|
.mockResolvedValueOnce({ theme: 'material', colorScheme: 'auto', persisted: true })
|
||||||
|
|
||||||
|
await syncAppearancePrefs(USER_ID)
|
||||||
|
|
||||||
|
expect(mockedApiJson).toHaveBeenCalledTimes(2)
|
||||||
|
expect(mockedApiJson).toHaveBeenLastCalledWith('/api/auth/appearance-prefs', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ theme: 'material', colorScheme: 'auto' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saveAppearancePrefsToServer skips when not authenticated', async () => {
|
||||||
|
await saveAppearancePrefsToServer('ocean', 'light')
|
||||||
|
expect(mockedApiJson).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('syncAppearancePrefs skips server sync when userId does not match active session', async () => {
|
||||||
|
localStorage.setItem('active_userid', 'session-user')
|
||||||
|
setThemePreference('other-user', 'ocean')
|
||||||
|
mockedApiJson.mockResolvedValue({
|
||||||
|
theme: 'material',
|
||||||
|
colorScheme: 'dark',
|
||||||
|
persisted: true
|
||||||
|
})
|
||||||
|
|
||||||
|
await syncAppearancePrefs('other-user')
|
||||||
|
|
||||||
|
expect(mockedApiJson).not.toHaveBeenCalled()
|
||||||
|
expect(localStorage.getItem('user_pref_theme_other-user')).toBe('ocean')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('syncAppearancePrefs skips server sync when active session is missing', async () => {
|
||||||
|
setThemePreference(USER_ID, 'ocean')
|
||||||
|
|
||||||
|
await syncAppearancePrefs(USER_ID)
|
||||||
|
|
||||||
|
expect(mockedApiJson).not.toHaveBeenCalled()
|
||||||
|
expect(localStorage.getItem(`user_pref_theme_${USER_ID}`)).toBe('ocean')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { apiJson } from './api.js'
|
||||||
|
import { notifyAppearanceChanged } from './appearance.js'
|
||||||
|
import {
|
||||||
|
getActiveUserId,
|
||||||
|
getColorSchemePreference,
|
||||||
|
getThemePreference,
|
||||||
|
setColorSchemePreference,
|
||||||
|
setThemePreference
|
||||||
|
} from './userPreferences.js'
|
||||||
|
|
||||||
|
const API_BASE = '/api/auth/appearance-prefs'
|
||||||
|
|
||||||
|
export interface AppearancePrefs {
|
||||||
|
theme: string
|
||||||
|
colorScheme: string
|
||||||
|
persisted: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasLocalAppearancePrefs(userId: string): boolean {
|
||||||
|
return (
|
||||||
|
localStorage.getItem(`user_pref_theme_${userId}`) != null ||
|
||||||
|
localStorage.getItem(`user_pref_color_scheme_${userId}`) != null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSyncedUserId(userId?: string | null): string | null {
|
||||||
|
const id = userId?.trim() || getActiveUserId()?.trim() || null
|
||||||
|
if (!id) return null
|
||||||
|
|
||||||
|
const activeId = getActiveUserId()?.trim() || null
|
||||||
|
if (!activeId || activeId !== id) return null
|
||||||
|
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAppearancePrefs(userId?: string | null): Promise<AppearancePrefs> {
|
||||||
|
if (!resolveSyncedUserId(userId)) {
|
||||||
|
return { theme: 'auto', colorScheme: 'auto', persisted: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiJson<AppearancePrefs>(API_BASE)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveAppearancePrefsToServer(
|
||||||
|
theme: string,
|
||||||
|
colorScheme: string,
|
||||||
|
userId?: string | null
|
||||||
|
): Promise<void> {
|
||||||
|
if (!resolveSyncedUserId(userId)) return
|
||||||
|
|
||||||
|
await apiJson<AppearancePrefs>(API_BASE, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ theme, colorScheme })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merge server-stored appearance with local cache (server wins after cache wipe). */
|
||||||
|
export async function syncAppearancePrefs(userId?: string | null): Promise<void> {
|
||||||
|
const id = resolveSyncedUserId(userId)
|
||||||
|
if (!id) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const server = await fetchAppearancePrefs(id)
|
||||||
|
|
||||||
|
if (server.persisted) {
|
||||||
|
setThemePreference(id, server.theme)
|
||||||
|
setColorSchemePreference(id, server.colorScheme)
|
||||||
|
} else if (hasLocalAppearancePrefs(id)) {
|
||||||
|
await saveAppearancePrefsToServer(getThemePreference(id), getColorSchemePreference(id), id)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to sync appearance preferences:', err)
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyAppearanceChanged()
|
||||||
|
}
|
||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -88,6 +88,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
|||||||
'Latitude', 'Longitude', 'Remarks',
|
'Latitude', 'Longitude', 'Remarks',
|
||||||
'Freshwater Morning (L)', 'Freshwater Refilled (L)', 'Freshwater Evening (L)', 'Freshwater Consumption (L)',
|
'Freshwater Morning (L)', 'Freshwater Refilled (L)', 'Freshwater Evening (L)', 'Freshwater Consumption (L)',
|
||||||
'Fuel Morning (L)', 'Fuel Refilled (L)', 'Fuel Evening (L)', 'Fuel Consumption (L)',
|
'Fuel Morning (L)', 'Fuel Refilled (L)', 'Fuel Evening (L)', 'Fuel Consumption (L)',
|
||||||
|
'Greywater Level (L)',
|
||||||
'Yacht Name', 'Home Port', 'Owner', 'Charter Company', 'Registration', 'Callsign', 'ATIS', 'MMSI'
|
'Yacht Name', 'Home Port', 'Owner', 'Charter Company', 'Registration', 'Callsign', 'ATIS', 'MMSI'
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -123,6 +124,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
|||||||
const fuelR = entry.fuel?.refilled ?? '';
|
const fuelR = entry.fuel?.refilled ?? '';
|
||||||
const fuelE = entry.fuel?.evening ?? '';
|
const fuelE = entry.fuel?.evening ?? '';
|
||||||
const fuelCons = entry.fuel?.consumption ?? '';
|
const fuelCons = entry.fuel?.consumption ?? '';
|
||||||
|
const greywaterLevel = entry.greywater?.level ?? '';
|
||||||
|
|
||||||
const eventsList = entry.events || [];
|
const eventsList = entry.events || [];
|
||||||
if (eventsList.length === 0) {
|
if (eventsList.length === 0) {
|
||||||
@@ -137,6 +139,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
|||||||
'', '', '',
|
'', '', '',
|
||||||
fwM, fwR, fwE, fwCons,
|
fwM, fwR, fwE, fwCons,
|
||||||
fuelM, fuelR, fuelE, fuelCons,
|
fuelM, fuelR, fuelE, fuelCons,
|
||||||
|
greywaterLevel,
|
||||||
yachtName, homePort, owner, charter, registration, callsign, atis, mmsi
|
yachtName, homePort, owner, charter, registration, callsign, atis, mmsi
|
||||||
].map(escapeCsvValue));
|
].map(escapeCsvValue));
|
||||||
} else {
|
} else {
|
||||||
@@ -153,6 +156,7 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
|||||||
ev.gpsLat || '', ev.gpsLng || '', ev.remarks || '',
|
ev.gpsLat || '', ev.gpsLng || '', ev.remarks || '',
|
||||||
fwM, fwR, fwE, fwCons,
|
fwM, fwR, fwE, fwCons,
|
||||||
fuelM, fuelR, fuelE, fuelCons,
|
fuelM, fuelR, fuelE, fuelCons,
|
||||||
|
greywaterLevel,
|
||||||
yachtName, homePort, owner, charter, registration, callsign, atis, mmsi
|
yachtName, homePort, owner, charter, registration, callsign, atis, mmsi
|
||||||
].map(escapeCsvValue));
|
].map(escapeCsvValue));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
clearDemoLogbookRefs,
|
||||||
|
getDemoFirstEntryStorageKey,
|
||||||
|
getDemoLogbookStorageKey
|
||||||
|
} from './demoLogbook.js'
|
||||||
|
|
||||||
|
describe('clearDemoLogbookRefs', () => {
|
||||||
|
const userId = 'user-1'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
localStorage.setItem('active_userid', userId)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes demo logbook and first-entry keys for the user', () => {
|
||||||
|
const logbookId = 'lb-demo'
|
||||||
|
localStorage.setItem(getDemoLogbookStorageKey(userId), logbookId)
|
||||||
|
localStorage.setItem(getDemoFirstEntryStorageKey(userId), 'entry-1')
|
||||||
|
|
||||||
|
clearDemoLogbookRefs(userId, logbookId)
|
||||||
|
|
||||||
|
expect(localStorage.getItem(getDemoLogbookStorageKey(userId))).toBeNull()
|
||||||
|
expect(localStorage.getItem(getDemoFirstEntryStorageKey(userId))).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not clear refs when logbookId does not match stored demo id', () => {
|
||||||
|
localStorage.setItem(getDemoLogbookStorageKey(userId), 'other-logbook')
|
||||||
|
localStorage.setItem(getDemoFirstEntryStorageKey(userId), 'entry-1')
|
||||||
|
|
||||||
|
clearDemoLogbookRefs(userId, 'deleted-logbook')
|
||||||
|
|
||||||
|
expect(localStorage.getItem(getDemoLogbookStorageKey(userId))).toBe('other-logbook')
|
||||||
|
expect(localStorage.getItem(getDemoFirstEntryStorageKey(userId))).toBe('entry-1')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -108,6 +108,7 @@ export async function seedDemoLogbookIfNeeded(): Promise<DemoSeedResult | null>
|
|||||||
const title = i18n.t('demo.logbook_title')
|
const title = i18n.t('demo.logbook_title')
|
||||||
return { logbookId: existingId, title, firstEntryId }
|
return { logbookId: existingId, title, firstEntryId }
|
||||||
}
|
}
|
||||||
|
clearDemoLogbookRefs(userId, existingId)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!shouldSeed) return null
|
if (!shouldSeed) return null
|
||||||
@@ -152,3 +153,66 @@ export function getStoredDemoFirstEntryId(): string | null {
|
|||||||
if (!userId) return null
|
if (!userId) return null
|
||||||
return localStorage.getItem(getDemoFirstEntryStorageKey(userId))
|
return localStorage.getItem(getDemoFirstEntryStorageKey(userId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Remove persisted demo logbook pointers when the logbook no longer exists. */
|
||||||
|
export function clearDemoLogbookRefs(userId: string, logbookId?: string): void {
|
||||||
|
const storedId = localStorage.getItem(getDemoLogbookStorageKey(userId))
|
||||||
|
if (logbookId && storedId && storedId !== logbookId) return
|
||||||
|
localStorage.removeItem(getDemoLogbookStorageKey(userId))
|
||||||
|
localStorage.removeItem(getDemoFirstEntryStorageKey(userId))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function entryExistsInLogbook(logbookId: string, entryId: string): Promise<boolean> {
|
||||||
|
const entry = await db.entries.get(entryId)
|
||||||
|
return entry?.logbookId === logbookId
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TourLogbookContext {
|
||||||
|
logbookId: string
|
||||||
|
title: string
|
||||||
|
firstEntryId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pick a logbook + first entry for the onboarding tour (handles deleted demo data). */
|
||||||
|
export async function resolveTourLogbookContext(
|
||||||
|
preferLogbookId?: string | null
|
||||||
|
): Promise<TourLogbookContext | null> {
|
||||||
|
const userId = localStorage.getItem('active_userid')
|
||||||
|
if (!userId || !getActiveMasterKey()) return null
|
||||||
|
|
||||||
|
const demoId = localStorage.getItem(getDemoLogbookStorageKey(userId))
|
||||||
|
if (demoId && !(await db.logbooks.get(demoId))) {
|
||||||
|
clearDemoLogbookRefs(userId, demoId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { fetchLogbooks } = await import('./logbook.js')
|
||||||
|
const books = await fetchLogbooks()
|
||||||
|
if (books.length === 0) return null
|
||||||
|
|
||||||
|
const activeId = localStorage.getItem('active_logbook_id')
|
||||||
|
const pick =
|
||||||
|
(preferLogbookId ? books.find((b) => b.id === preferLogbookId) : undefined) ??
|
||||||
|
(activeId ? books.find((b) => b.id === activeId) : undefined) ??
|
||||||
|
(demoId ? books.find((b) => b.id === demoId) : undefined) ??
|
||||||
|
books[0]
|
||||||
|
|
||||||
|
const firstEntryId = await resolveTourFirstEntryId(pick.id, userId)
|
||||||
|
return { logbookId: pick.id, title: pick.title, firstEntryId }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveTourFirstEntryId(logbookId: string, userId: string): Promise<string | null> {
|
||||||
|
const stored = localStorage.getItem(getDemoFirstEntryStorageKey(userId))
|
||||||
|
if (stored && (await entryExistsInLogbook(logbookId, stored))) {
|
||||||
|
return stored
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stored) {
|
||||||
|
localStorage.removeItem(getDemoFirstEntryStorageKey(userId))
|
||||||
|
}
|
||||||
|
|
||||||
|
const localEntries = await db.entries.where({ logbookId }).toArray()
|
||||||
|
if (localEntries.length === 0) return null
|
||||||
|
|
||||||
|
localEntries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
||||||
|
return localEntries[0]?.payloadId ?? null
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export interface DemoDaySpec {
|
|||||||
filename: string
|
filename: string
|
||||||
freshwater: { morning: number; refilled: number; evening: number; consumption: number }
|
freshwater: { morning: number; refilled: number; evening: number; consumption: number }
|
||||||
fuel: { morning: number; refilled: number; evening: number; consumption: number }
|
fuel: { morning: number; refilled: number; evening: number; consumption: number }
|
||||||
|
greywaterLevel?: number
|
||||||
motorHours?: number
|
motorHours?: number
|
||||||
events: Array<Record<string, string>>
|
events: Array<Record<string, string>>
|
||||||
}
|
}
|
||||||
@@ -69,6 +70,7 @@ export function buildDemoDays(): DemoDaySpec[] {
|
|||||||
filename: 'kiel-laboe.gpx',
|
filename: 'kiel-laboe.gpx',
|
||||||
freshwater: { morning: 120, refilled: 0, evening: 105, consumption: 15 },
|
freshwater: { morning: 120, refilled: 0, evening: 105, consumption: 15 },
|
||||||
fuel: { morning: 85, refilled: 0, evening: 78, consumption: 7 },
|
fuel: { morning: 85, refilled: 0, evening: 78, consumption: 7 },
|
||||||
|
greywaterLevel: 25,
|
||||||
events: [
|
events: [
|
||||||
{
|
{
|
||||||
time: '10:15',
|
time: '10:15',
|
||||||
@@ -101,6 +103,7 @@ export function buildDemoDays(): DemoDaySpec[] {
|
|||||||
filename: 'laboe-damp.gpx',
|
filename: 'laboe-damp.gpx',
|
||||||
freshwater: { morning: 105, refilled: 25, evening: 110, consumption: 20 },
|
freshwater: { morning: 105, refilled: 25, evening: 110, consumption: 20 },
|
||||||
fuel: { morning: 78, refilled: 0, evening: 70, consumption: 8 },
|
fuel: { morning: 78, refilled: 0, evening: 70, consumption: 8 },
|
||||||
|
greywaterLevel: 38,
|
||||||
motorHours: 1.5,
|
motorHours: 1.5,
|
||||||
events: [
|
events: [
|
||||||
{
|
{
|
||||||
@@ -134,6 +137,7 @@ export function buildDemoDays(): DemoDaySpec[] {
|
|||||||
filename: 'damp-schleimuende.gpx',
|
filename: 'damp-schleimuende.gpx',
|
||||||
freshwater: { morning: 110, refilled: 0, evening: 95, consumption: 15 },
|
freshwater: { morning: 110, refilled: 0, evening: 95, consumption: 15 },
|
||||||
fuel: { morning: 70, refilled: 15, evening: 80, consumption: 5 },
|
fuel: { morning: 70, refilled: 15, evening: 80, consumption: 5 },
|
||||||
|
greywaterLevel: 52,
|
||||||
events: [
|
events: [
|
||||||
{
|
{
|
||||||
time: '08:30',
|
time: '08:30',
|
||||||
@@ -176,7 +180,10 @@ export function buildDemoYachtData(): Record<string, unknown> {
|
|||||||
atis: '',
|
atis: '',
|
||||||
mmsi: '',
|
mmsi: '',
|
||||||
sails: isDe ? ['Großsegel', 'Genua', 'Spinnaker'] : ['Mainsail', 'Genoa', 'Spinnaker'],
|
sails: isDe ? ['Großsegel', 'Genua', 'Spinnaker'] : ['Mainsail', 'Genoa', 'Spinnaker'],
|
||||||
photo: null
|
photo: null,
|
||||||
|
freshwaterCapacityL: 200,
|
||||||
|
fuelCapacityL: 100,
|
||||||
|
greywaterCapacityL: 80
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,6 +251,10 @@ export function buildPublicDemoFixture(): PublicDemoFixture {
|
|||||||
events: day.events
|
events: day.events
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (day.greywaterLevel != null && day.greywaterLevel > 0) {
|
||||||
|
entryPayload.greywater = { level: day.greywaterLevel }
|
||||||
|
}
|
||||||
|
|
||||||
if (stats) {
|
if (stats) {
|
||||||
entryPayload.trackDistanceNm = stats.distanceNm
|
entryPayload.trackDistanceNm = stats.distanceNm
|
||||||
entryPayload.trackSpeedMaxKn = stats.speedMaxKn
|
entryPayload.trackSpeedMaxKn = stats.speedMaxKn
|
||||||
@@ -303,6 +314,10 @@ export function buildDemoEntryPayloads(): Array<{
|
|||||||
events: day.events
|
events: day.events
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (day.greywaterLevel != null && day.greywaterLevel > 0) {
|
||||||
|
entryPayload.greywater = { level: day.greywaterLevel }
|
||||||
|
}
|
||||||
|
|
||||||
if (stats) {
|
if (stats) {
|
||||||
entryPayload.trackDistanceNm = stats.distanceNm
|
entryPayload.trackDistanceNm = stats.distanceNm
|
||||||
entryPayload.trackSpeedMaxKn = stats.speedMaxKn
|
entryPayload.trackSpeedMaxKn = stats.speedMaxKn
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { encryptJson, decryptJson, encryptBuffer, decryptBuffer } from './crypto
|
|||||||
import { getLogbookKey, saveLogbookKey, generateLogbookKey } from './logbookKeys.js'
|
import { getLogbookKey, saveLogbookKey, generateLogbookKey } from './logbookKeys.js'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
|
||||||
import { apiFetch } from './api.js'
|
import { apiFetch } from './api.js'
|
||||||
|
import { clearDemoLogbookRefs, getDemoLogbookStorageKey } from './demoLogbook.js'
|
||||||
|
|
||||||
const API_BASE = '/api/logbooks'
|
const API_BASE = '/api/logbooks'
|
||||||
|
|
||||||
@@ -320,6 +321,9 @@ export async function deleteLogbook(id: string): Promise<void> {
|
|||||||
|
|
||||||
// Perform local cascading cleanup
|
// Perform local cascading cleanup
|
||||||
await deleteLocalLogbookCache(id)
|
await deleteLocalLogbookCache(id)
|
||||||
|
if (userId && id === localStorage.getItem(getDemoLogbookStorageKey(userId))) {
|
||||||
|
clearDemoLogbookRefs(userId, id)
|
||||||
|
}
|
||||||
trackPlausibleEvent(PlausibleEvents.LOGBOOK_DELETED)
|
trackPlausibleEvent(PlausibleEvents.LOGBOOK_DELETED)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -197,13 +197,15 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
|
|||||||
doc.text('VERBRAUCHSWERTE / CONSUMPTON STATS', 10, footerY + 3);
|
doc.text('VERBRAUCHSWERTE / CONSUMPTON STATS', 10, footerY + 3);
|
||||||
|
|
||||||
let fwY = footerY + 5;
|
let fwY = footerY + 5;
|
||||||
doc.rect(10, fwY, 110, rowHeight * 3, 'S');
|
const tankRows = 4;
|
||||||
|
doc.rect(10, fwY, 110, rowHeight * tankRows, 'S');
|
||||||
doc.line(10, fwY + rowHeight, 120, fwY + rowHeight);
|
doc.line(10, fwY + rowHeight, 120, fwY + rowHeight);
|
||||||
doc.line(10, fwY + rowHeight * 2, 120, fwY + rowHeight * 2);
|
doc.line(10, fwY + rowHeight * 2, 120, fwY + rowHeight * 2);
|
||||||
doc.line(40, fwY, 40, fwY + rowHeight * 3);
|
doc.line(10, fwY + rowHeight * 3, 120, fwY + rowHeight * 3);
|
||||||
doc.line(60, fwY, 60, fwY + rowHeight * 3);
|
doc.line(40, fwY, 40, fwY + rowHeight * tankRows);
|
||||||
doc.line(80, fwY, 80, fwY + rowHeight * 3);
|
doc.line(60, fwY, 60, fwY + rowHeight * tankRows);
|
||||||
doc.line(100, fwY, 100, fwY + rowHeight * 3);
|
doc.line(80, fwY, 80, fwY + rowHeight * tankRows);
|
||||||
|
doc.line(100, fwY, 100, fwY + rowHeight * tankRows);
|
||||||
|
|
||||||
doc.setFont('Helvetica', 'bold');
|
doc.setFont('Helvetica', 'bold');
|
||||||
doc.setFontSize(7.5);
|
doc.setFontSize(7.5);
|
||||||
@@ -226,6 +228,12 @@ export async function generateLogbookPagePdf(logbookId: string, entryId: string,
|
|||||||
doc.text(String(entry.fuel?.evening ?? '0'), 81, fwY + rowHeight * 2 + 4.2);
|
doc.text(String(entry.fuel?.evening ?? '0'), 81, fwY + rowHeight * 2 + 4.2);
|
||||||
doc.text(String(entry.fuel?.consumption ?? '0'), 101, fwY + rowHeight * 2 + 4.2);
|
doc.text(String(entry.fuel?.consumption ?? '0'), 101, fwY + rowHeight * 2 + 4.2);
|
||||||
|
|
||||||
|
doc.text('Grauwasser', 11, fwY + rowHeight * 3 + 4.2);
|
||||||
|
doc.text('—', 41, fwY + rowHeight * 3 + 4.2);
|
||||||
|
doc.text('—', 61, fwY + rowHeight * 3 + 4.2);
|
||||||
|
doc.text(String(entry.greywater?.level ?? '0'), 81, fwY + rowHeight * 3 + 4.2);
|
||||||
|
doc.text('—', 101, fwY + rowHeight * 3 + 4.2);
|
||||||
|
|
||||||
// Signatures Box
|
// Signatures Box
|
||||||
let sigX = 130;
|
let sigX = 130;
|
||||||
let sigY = footerY + 5;
|
let sigY = footerY + 5;
|
||||||
|
|||||||
@@ -43,6 +43,18 @@ async function fetchVapidPublicKey(): Promise<string | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True when crew-change push is enabled and notification permission is granted. */
|
||||||
|
export async function isCollaboratorPushActive(): Promise<boolean> {
|
||||||
|
if (!isPushSupported()) return false
|
||||||
|
if (getNotificationPermission() !== 'granted') return false
|
||||||
|
try {
|
||||||
|
const prefs = await fetchPushPrefs()
|
||||||
|
return prefs.collaboratorChangesEnabled
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchPushPrefs(): Promise<{ collaboratorChangesEnabled: boolean }> {
|
export async function fetchPushPrefs(): Promise<{ collaboratorChangesEnabled: boolean }> {
|
||||||
if (!localStorage.getItem('active_userid')) {
|
if (!localStorage.getItem('active_userid')) {
|
||||||
return { collaboratorChangesEnabled: false }
|
return { collaboratorChangesEnabled: false }
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
forcePwaRecovery,
|
||||||
|
markReloadAttempt,
|
||||||
|
recentlyAttemptedReload,
|
||||||
|
reconcileServiceWorkerOnStartup,
|
||||||
|
reconcileVersionOnStartup
|
||||||
|
} from './pwaStartup.js'
|
||||||
|
|
||||||
|
const STALE_RECOVERY_COUNT_KEY = 'pwa_stale_recovery_count'
|
||||||
|
const STALE_RECOVERY_LAST_KEY = 'pwa_stale_recovery_last_ts'
|
||||||
|
|
||||||
|
describe('pwaStartup reload guards', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blocks repeated reload attempts within the debounce window', () => {
|
||||||
|
expect(recentlyAttemptedReload(10_000)).toBe(false)
|
||||||
|
markReloadAttempt(10_000)
|
||||||
|
expect(recentlyAttemptedReload(12_000)).toBe(true)
|
||||||
|
expect(recentlyAttemptedReload(15_000)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('forcePwaRecovery stale counter reset', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear()
|
||||||
|
vi.unstubAllEnvs()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears stale recovery counter before hard recovery reload', async () => {
|
||||||
|
vi.stubEnv('DEV', false)
|
||||||
|
sessionStorage.setItem(STALE_RECOVERY_COUNT_KEY, '2')
|
||||||
|
sessionStorage.setItem(STALE_RECOVERY_LAST_KEY, String(Date.now()))
|
||||||
|
|
||||||
|
const reload = vi.fn()
|
||||||
|
vi.stubGlobal('location', { reload })
|
||||||
|
vi.stubGlobal('caches', {
|
||||||
|
keys: vi.fn().mockResolvedValue([]),
|
||||||
|
delete: vi.fn()
|
||||||
|
})
|
||||||
|
Object.defineProperty(navigator, 'serviceWorker', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
getRegistrations: vi.fn().mockResolvedValue([])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await forcePwaRecovery()
|
||||||
|
|
||||||
|
expect(sessionStorage.getItem(STALE_RECOVERY_COUNT_KEY)).toBeNull()
|
||||||
|
expect(sessionStorage.getItem(STALE_RECOVERY_LAST_KEY)).toBeNull()
|
||||||
|
expect(reload).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when hard recovery was just attempted', async () => {
|
||||||
|
sessionStorage.setItem('pwa_hard_recovery_ts', String(Date.now()))
|
||||||
|
const result = await forcePwaRecovery()
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('reconcileServiceWorkerOnStartup', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear()
|
||||||
|
vi.unstubAllEnvs()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false in dev mode', async () => {
|
||||||
|
vi.stubEnv('DEV', true)
|
||||||
|
await expect(reconcileServiceWorkerOnStartup()).resolves.toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when no waiting worker exists', async () => {
|
||||||
|
vi.stubEnv('DEV', false)
|
||||||
|
Object.defineProperty(navigator, 'serviceWorker', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
controller: {},
|
||||||
|
getRegistration: vi.fn().mockResolvedValue({
|
||||||
|
waiting: null,
|
||||||
|
installing: null,
|
||||||
|
update: vi.fn().mockResolvedValue(undefined),
|
||||||
|
addEventListener: vi.fn()
|
||||||
|
}),
|
||||||
|
addEventListener: vi.fn()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(reconcileServiceWorkerOnStartup()).resolves.toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when waiting worker activation never takes over', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const postMessage = vi.fn()
|
||||||
|
const addEventListener = vi.fn()
|
||||||
|
vi.stubEnv('DEV', false)
|
||||||
|
|
||||||
|
Object.defineProperty(navigator, 'serviceWorker', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
controller: { scriptURL: '/sw.js?v=1' },
|
||||||
|
getRegistration: vi.fn().mockResolvedValue({
|
||||||
|
waiting: { postMessage },
|
||||||
|
installing: null,
|
||||||
|
update: vi.fn().mockResolvedValue(undefined),
|
||||||
|
addEventListener: vi.fn()
|
||||||
|
}),
|
||||||
|
addEventListener
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const reconcilePromise = reconcileServiceWorkerOnStartup()
|
||||||
|
await vi.advanceTimersByTimeAsync(4_000)
|
||||||
|
|
||||||
|
await expect(reconcilePromise).resolves.toBe(false)
|
||||||
|
expect(postMessage).toHaveBeenCalledWith({ type: 'SKIP_WAITING' })
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('reconcileVersionOnStartup', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear()
|
||||||
|
vi.unstubAllEnvs()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns noop in dev mode', async () => {
|
||||||
|
vi.stubEnv('DEV', true)
|
||||||
|
await expect(reconcileVersionOnStartup()).resolves.toBe('noop')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns noop when deployed version matches bundled version', async () => {
|
||||||
|
vi.stubEnv('DEV', false)
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ version: '0.1.0.57' })
|
||||||
|
}))
|
||||||
|
vi.stubGlobal('__APP_VERSION__', '0.1.0.57')
|
||||||
|
|
||||||
|
await expect(reconcileVersionOnStartup()).resolves.toBe('noop')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import { isNewerAppVersion, fetchDeployedVersion, getAppVersion } from './pwaVersion.js'
|
||||||
|
|
||||||
|
const RELOAD_ATTEMPT_KEY = 'pwa_reload_attempt_ts'
|
||||||
|
const COLD_START_UPDATE_KEY = 'pwa_coldstart_update_ts'
|
||||||
|
const HARD_RECOVERY_KEY = 'pwa_hard_recovery_ts'
|
||||||
|
const STALE_RECOVERY_COUNT_KEY = 'pwa_stale_recovery_count'
|
||||||
|
const STALE_RECOVERY_LAST_KEY = 'pwa_stale_recovery_last_ts'
|
||||||
|
const STALE_RECOVERY_WINDOW_MS = 60_000
|
||||||
|
const RELOAD_DEBOUNCE_MS = 4_000
|
||||||
|
const COLD_START_UPDATE_DEBOUNCE_MS = 15_000
|
||||||
|
const HARD_RECOVERY_DEBOUNCE_MS = 30_000
|
||||||
|
|
||||||
|
export function recentlyAttemptedReload(now = Date.now()): boolean {
|
||||||
|
const last = Number(sessionStorage.getItem(RELOAD_ATTEMPT_KEY) || '0')
|
||||||
|
return now - last < RELOAD_DEBOUNCE_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markReloadAttempt(now = Date.now()): void {
|
||||||
|
sessionStorage.setItem(RELOAD_ATTEMPT_KEY, String(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
function recentlyAttemptedColdStartUpdate(now = Date.now()): boolean {
|
||||||
|
const last = Number(sessionStorage.getItem(COLD_START_UPDATE_KEY) || '0')
|
||||||
|
return now - last < COLD_START_UPDATE_DEBOUNCE_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
function markColdStartUpdateAttempt(now = Date.now()): void {
|
||||||
|
sessionStorage.setItem(COLD_START_UPDATE_KEY, String(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
function recentlyAttemptedHardRecovery(now = Date.now()): boolean {
|
||||||
|
const last = Number(sessionStorage.getItem(HARD_RECOVERY_KEY) || '0')
|
||||||
|
return now - last < HARD_RECOVERY_DEBOUNCE_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
function markHardRecoveryAttempt(now = Date.now()): void {
|
||||||
|
sessionStorage.setItem(HARD_RECOVERY_KEY, String(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetStaleRecoveryCount(): void {
|
||||||
|
sessionStorage.removeItem(STALE_RECOVERY_COUNT_KEY)
|
||||||
|
sessionStorage.removeItem(STALE_RECOVERY_LAST_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
function incrementStaleRecoveryCount(now = Date.now()): number {
|
||||||
|
const last = Number(sessionStorage.getItem(STALE_RECOVERY_LAST_KEY) || '0')
|
||||||
|
let current = Number(sessionStorage.getItem(STALE_RECOVERY_COUNT_KEY) || '0')
|
||||||
|
|
||||||
|
if (now - last > STALE_RECOVERY_WINDOW_MS) {
|
||||||
|
current = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = current + 1
|
||||||
|
sessionStorage.setItem(STALE_RECOVERY_COUNT_KEY, String(next))
|
||||||
|
sessionStorage.setItem(STALE_RECOVERY_LAST_KEY, String(now))
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStaleModuleLoadError(error: unknown): boolean {
|
||||||
|
const message =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: typeof error === 'string'
|
||||||
|
? error
|
||||||
|
: ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
message.includes('Failed to fetch dynamically imported module') ||
|
||||||
|
message.includes('Importing a module script failed') ||
|
||||||
|
message.includes('error loading dynamically imported module') ||
|
||||||
|
message.includes('Loading chunk') ||
|
||||||
|
message.includes('ChunkLoadError') ||
|
||||||
|
message.includes('Unable to preload CSS')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearPwaCachesAndWorkers(): Promise<void> {
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
const registrations = await navigator.serviceWorker.getRegistrations()
|
||||||
|
await Promise.all(registrations.map((registration) => registration.unregister()))
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('caches' in window) {
|
||||||
|
const keys = await caches.keys()
|
||||||
|
await Promise.all(keys.map((key) => caches.delete(key)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last-resort recovery when soft reloads cannot escape a stale precache.
|
||||||
|
* Equivalent to manually clearing site data / reinstalling the PWA.
|
||||||
|
*/
|
||||||
|
export async function forcePwaRecovery(): Promise<boolean> {
|
||||||
|
if (recentlyAttemptedHardRecovery()) return false
|
||||||
|
|
||||||
|
markHardRecoveryAttempt()
|
||||||
|
markReloadAttempt()
|
||||||
|
resetStaleRecoveryCount()
|
||||||
|
await clearPwaCachesAndWorkers()
|
||||||
|
window.location.reload()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForWaitingWorker(
|
||||||
|
registration: ServiceWorkerRegistration,
|
||||||
|
timeoutMs: number
|
||||||
|
): Promise<ServiceWorker | null> {
|
||||||
|
if (registration.waiting) {
|
||||||
|
return registration.waiting
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const timeoutId = window.setTimeout(() => resolve(null), timeoutMs)
|
||||||
|
|
||||||
|
const inspectWorker = (worker: ServiceWorker | null) => {
|
||||||
|
if (!worker) return
|
||||||
|
|
||||||
|
if (worker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||||
|
window.clearTimeout(timeoutId)
|
||||||
|
resolve(worker)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
worker.addEventListener(
|
||||||
|
'statechange',
|
||||||
|
() => {
|
||||||
|
if (worker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||||
|
window.clearTimeout(timeoutId)
|
||||||
|
resolve(worker)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ once: true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
inspectWorker(registration.installing)
|
||||||
|
|
||||||
|
registration.addEventListener(
|
||||||
|
'updatefound',
|
||||||
|
() => {
|
||||||
|
inspectWorker(registration.installing)
|
||||||
|
},
|
||||||
|
{ once: true }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function triggerServiceWorkerUpdate(timeoutMs = 5_000): Promise<boolean> {
|
||||||
|
if (import.meta.env.DEV || !('serviceWorker' in navigator)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const registration = await navigator.serviceWorker.getRegistration()
|
||||||
|
if (!registration) return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
await registration.update()
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const waiting = await waitForWaitingWorker(registration, timeoutMs)
|
||||||
|
return waiting !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function activateWaitingWorker(waiting: ServiceWorker): Promise<boolean> {
|
||||||
|
const currentController = navigator.serviceWorker.controller?.scriptURL ?? null
|
||||||
|
waiting.postMessage({ type: 'SKIP_WAITING' })
|
||||||
|
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
const timeoutId = window.setTimeout(() => resolve(false), 4_000)
|
||||||
|
navigator.serviceWorker.addEventListener(
|
||||||
|
'controllerchange',
|
||||||
|
() => {
|
||||||
|
window.clearTimeout(timeoutId)
|
||||||
|
const nextController = navigator.serviceWorker.controller?.scriptURL ?? null
|
||||||
|
resolve(nextController !== null && nextController !== currentController)
|
||||||
|
},
|
||||||
|
{ once: true }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After missed deploys, a waiting SW may exist while the page still runs an old bundle.
|
||||||
|
* Apply the waiting worker once on cold start (one controlled reload) instead of hanging.
|
||||||
|
*/
|
||||||
|
export async function reconcileServiceWorkerOnStartup(): Promise<boolean> {
|
||||||
|
if (import.meta.env.DEV || !('serviceWorker' in navigator)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recentlyAttemptedColdStartUpdate()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const registration = await navigator.serviceWorker.getRegistration()
|
||||||
|
let waiting = registration?.waiting ?? null
|
||||||
|
|
||||||
|
if (!waiting && registration) {
|
||||||
|
await registration.update().catch(() => {})
|
||||||
|
waiting = await waitForWaitingWorker(registration, 4_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!waiting || !navigator.serviceWorker.controller) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const activated = await activateWaitingWorker(waiting)
|
||||||
|
if (activated) {
|
||||||
|
markColdStartUpdateAttempt()
|
||||||
|
}
|
||||||
|
return activated
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare deployed version.json with the bundled app version.
|
||||||
|
* When the server is ahead, try a soft SW takeover before hard recovery.
|
||||||
|
*/
|
||||||
|
export async function reconcileVersionOnStartup(): Promise<'reload' | 'recovered' | 'noop'> {
|
||||||
|
if (import.meta.env.DEV || !navigator.onLine) {
|
||||||
|
return 'noop'
|
||||||
|
}
|
||||||
|
|
||||||
|
const deployedVersion = await fetchDeployedVersion()
|
||||||
|
if (!deployedVersion || !isNewerAppVersion(deployedVersion, getAppVersion())) {
|
||||||
|
return 'noop'
|
||||||
|
}
|
||||||
|
|
||||||
|
const reconciled = await reconcileServiceWorkerOnStartup()
|
||||||
|
if (reconciled) {
|
||||||
|
return 'reload'
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await triggerServiceWorkerUpdate()
|
||||||
|
if (updated) {
|
||||||
|
const registration = await navigator.serviceWorker.getRegistration()
|
||||||
|
const waiting = registration?.waiting
|
||||||
|
if (waiting) {
|
||||||
|
const activated = await activateWaitingWorker(waiting)
|
||||||
|
if (activated) {
|
||||||
|
markColdStartUpdateAttempt()
|
||||||
|
return 'reload'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!recentlyAttemptedHardRecovery()) {
|
||||||
|
const recovered = await forcePwaRecovery()
|
||||||
|
if (recovered) {
|
||||||
|
return 'recovered'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'noop'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function installStaleAssetRecovery(): void {
|
||||||
|
if (import.meta.env.DEV) return
|
||||||
|
|
||||||
|
const recoverFromStaleAssets = () => {
|
||||||
|
if (recentlyAttemptedReload()) return
|
||||||
|
|
||||||
|
const attempts = incrementStaleRecoveryCount()
|
||||||
|
markReloadAttempt()
|
||||||
|
|
||||||
|
if (attempts >= 2) {
|
||||||
|
void forcePwaRecovery()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('unhandledrejection', (event) => {
|
||||||
|
if (!isStaleModuleLoadError(event.reason)) return
|
||||||
|
event.preventDefault()
|
||||||
|
recoverFromStaleAssets()
|
||||||
|
})
|
||||||
|
|
||||||
|
window.addEventListener(
|
||||||
|
'error',
|
||||||
|
(event) => {
|
||||||
|
if (!isStaleModuleLoadError(event.message)) return
|
||||||
|
recoverFromStaleAssets()
|
||||||
|
},
|
||||||
|
true
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
compareAppVersions,
|
||||||
|
isNewerAppVersion,
|
||||||
|
parseAppVersion
|
||||||
|
} from './pwaVersion.js'
|
||||||
|
|
||||||
|
describe('pwaVersion', () => {
|
||||||
|
it('parses semantic build versions', () => {
|
||||||
|
expect(parseAppVersion('v0.1.0.57')).toEqual([0, 1, 0, 57])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('compares build numbers numerically', () => {
|
||||||
|
expect(compareAppVersions('0.1.0.65', '0.1.0.57')).toBeGreaterThan(0)
|
||||||
|
expect(compareAppVersions('0.1.0.57', '0.1.0.65')).toBeLessThan(0)
|
||||||
|
expect(compareAppVersions('0.1.0.57', '0.1.0.57')).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detects newer deployed versions', () => {
|
||||||
|
expect(isNewerAppVersion('0.1.0.66', '0.1.0.57')).toBe(true)
|
||||||
|
expect(isNewerAppVersion('0.1.0.57', '0.1.0.57')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
const APP_VERSION =
|
||||||
|
typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0.0-dev'
|
||||||
|
|
||||||
|
export function getAppVersion(): string {
|
||||||
|
return APP_VERSION
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAppVersion(version: string): number[] {
|
||||||
|
return version
|
||||||
|
.replace(/^v/i, '')
|
||||||
|
.split('.')
|
||||||
|
.map((part) => Number.parseInt(part, 10) || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Positive when `a` is newer than `b`. */
|
||||||
|
export function compareAppVersions(a: string, b: string): number {
|
||||||
|
const partsA = parseAppVersion(a)
|
||||||
|
const partsB = parseAppVersion(b)
|
||||||
|
const length = Math.max(partsA.length, partsB.length)
|
||||||
|
|
||||||
|
for (let index = 0; index < length; index += 1) {
|
||||||
|
const diff = (partsA[index] ?? 0) - (partsB[index] ?? 0)
|
||||||
|
if (diff !== 0) return diff
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNewerAppVersion(serverVersion: string, clientVersion: string): boolean {
|
||||||
|
return compareAppVersions(serverVersion, clientVersion) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDeployedVersion(timeoutMs = 4_000): Promise<string | null> {
|
||||||
|
if (!navigator.onLine) return null
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/version.json?_=${Date.now()}`, {
|
||||||
|
cache: 'no-store',
|
||||||
|
signal: controller.signal
|
||||||
|
})
|
||||||
|
if (!response.ok) return null
|
||||||
|
|
||||||
|
const payload = (await response.json()) as { version?: unknown }
|
||||||
|
return typeof payload.version === 'string' ? payload.version.trim() : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isDeployedVersionNewer(): Promise<boolean> {
|
||||||
|
const deployedVersion = await fetchDeployedVersion()
|
||||||
|
if (!deployedVersion) return false
|
||||||
|
return isNewerAppVersion(deployedVersion, getAppVersion())
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
+28
-1
@@ -1,10 +1,37 @@
|
|||||||
/// <reference lib="webworker" />
|
/// <reference lib="webworker" />
|
||||||
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching'
|
import { clientsClaim } from 'workbox-core'
|
||||||
|
import { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute } from 'workbox-precaching'
|
||||||
|
import { registerRoute } from 'workbox-routing'
|
||||||
|
import { NetworkFirst, NetworkOnly } from 'workbox-strategies'
|
||||||
|
|
||||||
declare let self: ServiceWorkerGlobalScope
|
declare let self: ServiceWorkerGlobalScope
|
||||||
|
|
||||||
|
const appShellFallback = createHandlerBoundToURL('/index.html')
|
||||||
|
const navigationStrategy = new NetworkFirst({
|
||||||
|
cacheName: 'app-shell',
|
||||||
|
networkTimeoutSeconds: 3
|
||||||
|
})
|
||||||
|
|
||||||
|
registerRoute(({ request }) => request.mode === 'navigate', async (context) => {
|
||||||
|
try {
|
||||||
|
return await navigationStrategy.handle(context)
|
||||||
|
} catch {
|
||||||
|
return appShellFallback(context)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
precacheAndRoute(self.__WB_MANIFEST)
|
precacheAndRoute(self.__WB_MANIFEST)
|
||||||
cleanupOutdatedCaches()
|
cleanupOutdatedCaches()
|
||||||
|
clientsClaim()
|
||||||
|
|
||||||
|
// Always fetch the live deploy version, even under an older precache.
|
||||||
|
registerRoute(({ url }) => url.pathname === '/version.json', new NetworkOnly())
|
||||||
|
|
||||||
|
self.addEventListener('message', (event) => {
|
||||||
|
if (event.data?.type === 'SKIP_WAITING') {
|
||||||
|
void self.skipWaiting()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
interface PushPayload {
|
interface PushPayload {
|
||||||
title?: string
|
title?: string
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -396,3 +403,12 @@ html.scheme-light.theme-cupertino {
|
|||||||
html.scheme-light #root {
|
html.scheme-light #root {
|
||||||
border-inline-color: var(--app-border-subtle);
|
border-inline-color: var(--app-border-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Bridge legacy index.css tokens to appearance (avoids system color-scheme drift) */
|
||||||
|
html.scheme-light,
|
||||||
|
html.scheme-dark {
|
||||||
|
--text: var(--app-text);
|
||||||
|
--text-h: var(--app-text-heading);
|
||||||
|
--code-bg: var(--app-icon-btn-bg);
|
||||||
|
--border: var(--app-border-subtle);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
buildLogEntryPayload,
|
||||||
|
hasUnsavedEventDraft,
|
||||||
|
isLogEventDraftEmpty,
|
||||||
|
normalizeLogEvent,
|
||||||
|
type LogEventPayload
|
||||||
|
} from './logEntryPayload.js'
|
||||||
|
|
||||||
|
const emptyDraft = (): LogEventPayload =>
|
||||||
|
normalizeLogEvent({ time: '12:34' })
|
||||||
|
|
||||||
|
const filledDraft = (): LogEventPayload =>
|
||||||
|
normalizeLogEvent({ time: '12:34', remarks: 'Wind dreht' })
|
||||||
|
|
||||||
|
describe('logEntryPayload event drafts', () => {
|
||||||
|
it('treats time-only draft as empty', () => {
|
||||||
|
expect(isLogEventDraftEmpty(emptyDraft())).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detects draft with content', () => {
|
||||||
|
expect(isLogEventDraftEmpty(filledDraft())).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not flag empty open form as unsaved', () => {
|
||||||
|
expect(hasUnsavedEventDraft(emptyDraft(), null, [])).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flags new event draft with content as unsaved', () => {
|
||||||
|
expect(hasUnsavedEventDraft(filledDraft(), null, [])).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('flags edited event when values differ', () => {
|
||||||
|
const events = [emptyDraft()]
|
||||||
|
const edited = filledDraft()
|
||||||
|
expect(hasUnsavedEventDraft(edited, 0, events)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores edit mode when values match', () => {
|
||||||
|
const events = [filledDraft()]
|
||||||
|
expect(hasUnsavedEventDraft(filledDraft(), 0, events)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildLogEntryPayload greywater', () => {
|
||||||
|
const base = {
|
||||||
|
date: '2026-05-31',
|
||||||
|
dayOfTravel: '1',
|
||||||
|
departure: 'Kiel',
|
||||||
|
destination: 'Laboe',
|
||||||
|
freshwater: { morning: 0, refilled: 0, evening: 0, consumption: 0 },
|
||||||
|
fuel: { morning: 0, refilled: 0, evening: 0, consumption: 0 },
|
||||||
|
events: [] as LogEventPayload[]
|
||||||
|
}
|
||||||
|
|
||||||
|
it('includes greywater when level > 0', () => {
|
||||||
|
const payload = buildLogEntryPayload({ ...base, greywater: { level: 45 } })
|
||||||
|
expect(payload.greywater).toEqual({ level: 45 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits greywater when level is 0', () => {
|
||||||
|
const payload = buildLogEntryPayload({ ...base, greywater: { level: 0 } })
|
||||||
|
expect(payload.greywater).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -111,6 +111,27 @@ export function logEventsEqual(a: LogEventPayload, b: LogEventPayload): boolean
|
|||||||
return LOG_EVENT_FIELDS.every((key) => a[key] === b[key])
|
return LOG_EVENT_FIELDS.every((key) => a[key] === b[key])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LOG_EVENT_CONTENT_FIELDS = LOG_EVENT_FIELDS.filter((key) => key !== 'time')
|
||||||
|
|
||||||
|
/** Draft with only a time (or empty fields) — not an unsaved log entry change. */
|
||||||
|
export function isLogEventDraftEmpty(event: LogEventPayload): boolean {
|
||||||
|
return LOG_EVENT_CONTENT_FIELDS.every((key) => !event[key]?.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the event form holds unsaved changes worth merging on page save. */
|
||||||
|
export function hasUnsavedEventDraft(
|
||||||
|
draft: LogEventPayload,
|
||||||
|
editingEventIndex: number | null,
|
||||||
|
events: LogEventPayload[]
|
||||||
|
): boolean {
|
||||||
|
if (!isValidTimeHHMM(draft.time)) return false
|
||||||
|
if (editingEventIndex !== null) {
|
||||||
|
const original = events[editingEventIndex]
|
||||||
|
return original ? !logEventsEqual(draft, original) : false
|
||||||
|
}
|
||||||
|
return !isLogEventDraftEmpty(draft)
|
||||||
|
}
|
||||||
|
|
||||||
/** Chronological order: earliest time first (HH:MM). */
|
/** Chronological order: earliest time first (HH:MM). */
|
||||||
export function sortLogEventsByTime<T extends LogEventPayload>(events: T[]): T[] {
|
export function sortLogEventsByTime<T extends LogEventPayload>(events: T[]): T[] {
|
||||||
return [...events].sort((a, b) => (a.time || '').localeCompare(b.time || ''))
|
return [...events].sort((a, b) => (a.time || '').localeCompare(b.time || ''))
|
||||||
@@ -123,6 +144,7 @@ export interface LogEntryPayloadInput {
|
|||||||
destination: string
|
destination: string
|
||||||
freshwater: { morning: number; refilled: number; evening: number; consumption: number }
|
freshwater: { morning: number; refilled: number; evening: number; consumption: number }
|
||||||
fuel: { morning: number; refilled: number; evening: number; consumption: number }
|
fuel: { morning: number; refilled: number; evening: number; consumption: number }
|
||||||
|
greywater?: { level: number }
|
||||||
trackDistanceNm?: number
|
trackDistanceNm?: number
|
||||||
trackSpeedMaxKn?: number
|
trackSpeedMaxKn?: number
|
||||||
trackSpeedAvgKn?: number
|
trackSpeedAvgKn?: number
|
||||||
@@ -148,5 +170,12 @@ export function buildLogEntryPayload(input: LogEntryPayloadInput): Record<string
|
|||||||
payload.motorHours = Number(input.motorHours.toFixed(2))
|
payload.motorHours = Number(input.motorHours.toFixed(2))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (input.greywater !== undefined) {
|
||||||
|
const level = Number(input.greywater.level) || 0
|
||||||
|
if (level > 0) {
|
||||||
|
payload.greywater = { level: Number(level.toFixed(1)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
carryOverFromPreviousDay,
|
||||||
|
getClosingGreywaterLevel,
|
||||||
|
hasCarryOverFromPreviousDay
|
||||||
|
} from './logEntryTankLevels.js'
|
||||||
|
|
||||||
|
describe('logEntryTankLevels greywater carry-over', () => {
|
||||||
|
it('returns previous greywater level as starting value', () => {
|
||||||
|
const carryOver = carryOverFromPreviousDay({
|
||||||
|
destination: 'Oslo',
|
||||||
|
freshwater: { morning: 100, refilled: 0, evening: 80, consumption: 20 },
|
||||||
|
fuel: { morning: 200, refilled: 0, evening: 150, consumption: 50 },
|
||||||
|
greywater: { level: 42 }
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(carryOver.greywaterLevel).toBe(42)
|
||||||
|
expect(carryOver.freshwater.morning).toBe(80)
|
||||||
|
expect(carryOver.fuel.morning).toBe(150)
|
||||||
|
expect(carryOver.departure).toBe('Oslo')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults greywater to 0 when previous day has none', () => {
|
||||||
|
expect(carryOverFromPreviousDay(null).greywaterLevel).toBe(0)
|
||||||
|
expect(getClosingGreywaterLevel(undefined)).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats greywater level as carry-over candidate', () => {
|
||||||
|
expect(
|
||||||
|
hasCarryOverFromPreviousDay({
|
||||||
|
freshwater: { morning: 0, refilled: 0, evening: 0, consumption: 0 },
|
||||||
|
fuel: { morning: 0, refilled: 0, evening: 0, consumption: 0 },
|
||||||
|
greywaterLevel: 15,
|
||||||
|
departure: ''
|
||||||
|
})
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -41,12 +41,14 @@ export function getClosingTankLevel(tank?: Partial<TankLevels> | null): number {
|
|||||||
export interface LogEntryTankSource {
|
export interface LogEntryTankSource {
|
||||||
freshwater?: Partial<TankLevels>
|
freshwater?: Partial<TankLevels>
|
||||||
fuel?: Partial<TankLevels>
|
fuel?: Partial<TankLevels>
|
||||||
|
greywater?: { level?: number }
|
||||||
destination?: string
|
destination?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CarryOverFromPreviousDay {
|
export interface CarryOverFromPreviousDay {
|
||||||
freshwater: TankLevels
|
freshwater: TankLevels
|
||||||
fuel: TankLevels
|
fuel: TankLevels
|
||||||
|
greywaterLevel: number
|
||||||
departure: string
|
departure: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +61,10 @@ export function formatTankLiters(liters: number): string {
|
|||||||
return Number.isInteger(liters) ? String(liters) : liters.toFixed(1)
|
return Number.isInteger(liters) ? String(liters) : liters.toFixed(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getClosingGreywaterLevel(greywater?: { level?: number } | null): number {
|
||||||
|
return Number(greywater?.level) || 0
|
||||||
|
}
|
||||||
|
|
||||||
export function carryOverTankLevelsFromPreviousDay(previousEntry?: LogEntryTankSource | null): { freshwater: TankLevels; fuel: TankLevels } {
|
export function carryOverTankLevelsFromPreviousDay(previousEntry?: LogEntryTankSource | null): { freshwater: TankLevels; fuel: TankLevels } {
|
||||||
if (!previousEntry) {
|
if (!previousEntry) {
|
||||||
return { freshwater: emptyTankLevels(), fuel: emptyTankLevels() }
|
return { freshwater: emptyTankLevels(), fuel: emptyTankLevels() }
|
||||||
@@ -73,10 +79,16 @@ export function carryOverTankLevelsFromPreviousDay(previousEntry?: LogEntryTankS
|
|||||||
export function carryOverFromPreviousDay(previousEntry?: LogEntryTankSource | null): CarryOverFromPreviousDay {
|
export function carryOverFromPreviousDay(previousEntry?: LogEntryTankSource | null): CarryOverFromPreviousDay {
|
||||||
const { freshwater, fuel } = carryOverTankLevelsFromPreviousDay(previousEntry)
|
const { freshwater, fuel } = carryOverTankLevelsFromPreviousDay(previousEntry)
|
||||||
const departure = previousEntry?.destination?.trim() || ''
|
const departure = previousEntry?.destination?.trim() || ''
|
||||||
|
const greywaterLevel = getClosingGreywaterLevel(previousEntry?.greywater)
|
||||||
|
|
||||||
return { freshwater, fuel, departure }
|
return { freshwater, fuel, greywaterLevel, departure }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasCarryOverFromPreviousDay(carryOver: CarryOverFromPreviousDay): boolean {
|
export function hasCarryOverFromPreviousDay(carryOver: CarryOverFromPreviousDay): boolean {
|
||||||
return carryOver.freshwater.morning > 0 || carryOver.fuel.morning > 0 || carryOver.departure.length > 0
|
return (
|
||||||
|
carryOver.freshwater.morning > 0 ||
|
||||||
|
carryOver.fuel.morning > 0 ||
|
||||||
|
carryOver.greywaterLevel > 0 ||
|
||||||
|
carryOver.departure.length > 0
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
clampTankLiters,
|
||||||
|
computeEveningTankMaxLiters,
|
||||||
|
computeRefilledTankMaxLiters,
|
||||||
|
extractTankCapacitiesFromYacht,
|
||||||
|
formatTankLitersForInput,
|
||||||
|
parseOptionalTankLiters,
|
||||||
|
tankCapacityInputFromStored
|
||||||
|
} from './tankCapacity.js'
|
||||||
|
|
||||||
|
describe('tankCapacity', () => {
|
||||||
|
it('parses optional liters with comma decimal', () => {
|
||||||
|
expect(parseOptionalTankLiters('200')).toBe(200)
|
||||||
|
expect(parseOptionalTankLiters('12,5')).toBe(12.5)
|
||||||
|
expect(parseOptionalTankLiters('')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects negative or invalid liters', () => {
|
||||||
|
expect(() => parseOptionalTankLiters('-1')).toThrow('invalid_tank_liters')
|
||||||
|
expect(() => parseOptionalTankLiters('abc')).toThrow('invalid_tank_liters')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extracts capacities from yacht payload', () => {
|
||||||
|
expect(
|
||||||
|
extractTankCapacitiesFromYacht({
|
||||||
|
freshwaterCapacityL: 300,
|
||||||
|
fuelCapacityL: 120,
|
||||||
|
greywaterCapacityL: 80
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
freshwaterCapacityL: 300,
|
||||||
|
fuelCapacityL: 120,
|
||||||
|
greywaterCapacityL: 80
|
||||||
|
})
|
||||||
|
expect(extractTankCapacitiesFromYacht({ name: 'Test' })).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats stored capacity for input', () => {
|
||||||
|
expect(tankCapacityInputFromStored(150)).toBe('150')
|
||||||
|
expect(formatTankLitersForInput(12.5)).toBe('12.5')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clamps liters to max when set', () => {
|
||||||
|
expect(clampTankLiters(250, 200)).toBe(200)
|
||||||
|
expect(clampTankLiters(-5, 200)).toBe(0)
|
||||||
|
expect(clampTankLiters(50)).toBe(50)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('computes refilled max as capacity minus morning', () => {
|
||||||
|
expect(computeRefilledTankMaxLiters('10', 60)).toBe(50)
|
||||||
|
expect(computeRefilledTankMaxLiters('60', 60)).toBeUndefined()
|
||||||
|
expect(computeRefilledTankMaxLiters('10', undefined)).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('computes evening max as morning plus refilled capped by capacity', () => {
|
||||||
|
expect(computeEveningTankMaxLiters('10', '20', 60)).toBe(30)
|
||||||
|
expect(computeEveningTankMaxLiters('40', '40', 60)).toBe(60)
|
||||||
|
expect(computeEveningTankMaxLiters('10', '20')).toBe(30)
|
||||||
|
expect(computeEveningTankMaxLiters('0', '0', 60)).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { formatTankLiters } from './logEntryTankLevels.js'
|
||||||
|
|
||||||
|
export interface VesselTankCapacities {
|
||||||
|
freshwaterCapacityL?: number
|
||||||
|
fuelCapacityL?: number
|
||||||
|
greywaterCapacityL?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseOptionalTankLiters(input: string): number | undefined {
|
||||||
|
const trimmed = input.trim().replace(',', '.')
|
||||||
|
if (!trimmed) return undefined
|
||||||
|
const parsed = Number(trimmed)
|
||||||
|
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||||
|
throw new Error('invalid_tank_liters')
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTankLitersForInput(liters: number): string {
|
||||||
|
return formatTankLiters(liters)
|
||||||
|
}
|
||||||
|
|
||||||
|
function capacityFromStored(value: unknown): number | undefined {
|
||||||
|
if (value == null || value === '') return undefined
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) return value
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const trimmed = value.trim().replace(',', '.')
|
||||||
|
if (!trimmed) return undefined
|
||||||
|
const parsed = Number(trimmed)
|
||||||
|
if (Number.isFinite(parsed) && parsed >= 0) return parsed
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tankCapacityInputFromStored(value: unknown): string {
|
||||||
|
const n = capacityFromStored(value)
|
||||||
|
return n != null ? formatTankLitersForInput(n) : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractTankCapacitiesFromYacht(decrypted: unknown): VesselTankCapacities {
|
||||||
|
if (!decrypted || typeof decrypted !== 'object') return {}
|
||||||
|
const y = decrypted as Record<string, unknown>
|
||||||
|
const capacities: VesselTankCapacities = {}
|
||||||
|
const fw = capacityFromStored(y.freshwaterCapacityL)
|
||||||
|
const fuel = capacityFromStored(y.fuelCapacityL)
|
||||||
|
const gw = capacityFromStored(y.greywaterCapacityL)
|
||||||
|
if (fw != null) capacities.freshwaterCapacityL = fw
|
||||||
|
if (fuel != null) capacities.fuelCapacityL = fuel
|
||||||
|
if (gw != null) capacities.greywaterCapacityL = gw
|
||||||
|
return capacities
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a liter amount from form state (string). */
|
||||||
|
export function parseTankLitersFromInput(input: string): number {
|
||||||
|
const trimmed = input.trim().replace(',', '.')
|
||||||
|
if (!trimmed) return 0
|
||||||
|
const parsed = Number(trimmed)
|
||||||
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Max for refilled amount: remaining capacity after morning level.
|
||||||
|
* Returns undefined when no positive max (no slider).
|
||||||
|
*/
|
||||||
|
export function computeRefilledTankMaxLiters(
|
||||||
|
morningInput: string,
|
||||||
|
tankCapacityL?: number
|
||||||
|
): number | undefined {
|
||||||
|
if (tankCapacityL == null || tankCapacityL <= 0) return undefined
|
||||||
|
const remaining = tankCapacityL - parseTankLitersFromInput(morningInput)
|
||||||
|
if (remaining <= 0) return undefined
|
||||||
|
return remaining
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Max for evening fill level: morning + refilled, capped by tank capacity when known.
|
||||||
|
* Returns undefined when no positive max (no slider).
|
||||||
|
*/
|
||||||
|
export function computeEveningTankMaxLiters(
|
||||||
|
morningInput: string,
|
||||||
|
refilledInput: string,
|
||||||
|
tankCapacityL?: number
|
||||||
|
): number | undefined {
|
||||||
|
const sum = parseTankLitersFromInput(morningInput) + parseTankLitersFromInput(refilledInput)
|
||||||
|
if (sum <= 0) return undefined
|
||||||
|
|
||||||
|
if (tankCapacityL != null && tankCapacityL > 0) {
|
||||||
|
return Math.min(tankCapacityL, sum)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sum
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clamp numeric liter value to [0, max] when max is known. */
|
||||||
|
export function clampTankLiters(value: number, maxLiters?: number): number {
|
||||||
|
const clamped = Math.max(0, value)
|
||||||
|
if (maxLiters != null && maxLiters > 0) {
|
||||||
|
return Math.min(clamped, maxLiters)
|
||||||
|
}
|
||||||
|
return clamped
|
||||||
|
}
|
||||||
+16
-1
@@ -2,9 +2,10 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import { VitePWA } from 'vite-plugin-pwa'
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync, writeFileSync } from 'node:fs'
|
||||||
import { resolve, dirname } from 'node:path'
|
import { resolve, dirname } from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import type { Plugin } from 'vite'
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
@@ -19,6 +20,19 @@ function readAppVersion(): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function versionJsonPlugin(version: string): Plugin {
|
||||||
|
return {
|
||||||
|
name: 'version-json',
|
||||||
|
writeBundle(options) {
|
||||||
|
const outDir = options.dir ?? resolve(__dirname, 'dist')
|
||||||
|
writeFileSync(
|
||||||
|
resolve(outDir, 'version.json'),
|
||||||
|
`${JSON.stringify({ version }, null, 2)}\n`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
@@ -42,6 +56,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
|
versionJsonPlugin(readAppVersion()),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
strategies: 'injectManifest',
|
strategies: 'injectManifest',
|
||||||
srcDir: 'src',
|
srcDir: 'src',
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,13 +322,14 @@
|
|||||||
<div class="feature"><span class="feature-icon">✦</span><span>GPS-Track Upload (GPX/KML) mit Kartendarstellung</span></div>
|
<div class="feature"><span class="feature-icon">✦</span><span>GPS-Track Upload (GPX/KML) mit Kartendarstellung</span></div>
|
||||||
<div class="feature"><span class="feature-icon">✦</span><span>Streckenstatistik</span></div>
|
<div class="feature"><span class="feature-icon">✦</span><span>Streckenstatistik</span></div>
|
||||||
<div class="feature"><span class="feature-icon">✦</span><span>Foto-Anhänge pro Reisetag</span></div>
|
<div class="feature"><span class="feature-icon">✦</span><span>Foto-Anhänge pro Reisetag</span></div>
|
||||||
<div class="feature"><span class="feature-icon">✦</span><span>Foto-Anhänge für Skipper und Crew</span></div>
|
<div class="feature"><span class="feature-icon">✦</span><span>Foto-Avatarbilder für Skipper und Crew</span></div>
|
||||||
<div class="feature"><span class="feature-icon">✦</span><span>Crew einladen — gemeinsam am Logbuch arbeiten</span></div>
|
<div class="feature"><span class="feature-icon">✦</span><span>Crew einladen — gemeinsam am Logbuch arbeiten</span></div>
|
||||||
<div class="feature"><span class="feature-icon">✦</span><span>PDF- & CSV-Export</span></div>
|
<div class="feature"><span class="feature-icon">✦</span><span>PDF- & CSV-Export</span></div>
|
||||||
<div class="feature"><span class="feature-icon">✦</span><span>Verschlüsseltes Backup & Wiederherstellung</span></div>
|
<div class="feature"><span class="feature-icon">✦</span><span>Verschlüsseltes Backup & Wiederherstellung</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>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">&</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">&</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.
@@ -59,7 +59,7 @@ bump_patch_version() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ensure_clean_git_tree() {
|
ensure_clean_git_tree() {
|
||||||
if git diff-index --quiet HEAD -- && [ -z "$(git status --porcelain)" ]; then
|
if [ -z "$(git status --porcelain)" ]; then
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ model User {
|
|||||||
collaborations Collaboration[]
|
collaborations Collaboration[]
|
||||||
pushSubscriptions PushSubscription[]
|
pushSubscriptions PushSubscription[]
|
||||||
notificationPrefs UserNotificationPrefs?
|
notificationPrefs UserNotificationPrefs?
|
||||||
|
appearancePrefs UserAppearancePrefs?
|
||||||
}
|
}
|
||||||
|
|
||||||
model PushSubscription {
|
model PushSubscription {
|
||||||
@@ -48,6 +49,15 @@ model UserNotificationPrefs {
|
|||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model UserAppearancePrefs {
|
||||||
|
userId String @id
|
||||||
|
theme String @default("auto")
|
||||||
|
colorScheme String @default("auto")
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
model Credential {
|
model Credential {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String
|
userId String
|
||||||
|
|||||||
@@ -38,6 +38,17 @@ function normalizeCredentialLabel(label: unknown): string | null {
|
|||||||
return trimmed.slice(0, 64)
|
return trimmed.slice(0, 64)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VALID_THEMES = new Set(['auto', 'ocean', 'material', 'cupertino'])
|
||||||
|
const VALID_COLOR_SCHEMES = new Set(['auto', 'light', 'dark'])
|
||||||
|
|
||||||
|
function parseThemePreference(value: unknown): string | null {
|
||||||
|
return typeof value === 'string' && VALID_THEMES.has(value) ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseColorSchemePreference(value: unknown): string | null {
|
||||||
|
return typeof value === 'string' && VALID_COLOR_SCHEMES.has(value) ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
router.post('/register-options', async (req, res) => {
|
router.post('/register-options', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { username } = req.body
|
const { username } = req.body
|
||||||
@@ -426,6 +437,57 @@ router.post('/rotate-recovery', requireReauth, async (req: any, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.get('/appearance-prefs', requireUser, async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const prefs = await prisma.userAppearancePrefs.findUnique({
|
||||||
|
where: { userId: req.userId }
|
||||||
|
})
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
theme: prefs?.theme ?? 'auto',
|
||||||
|
colorScheme: prefs?.colorScheme ?? 'auto',
|
||||||
|
persisted: prefs != null
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error reading appearance prefs:', error)
|
||||||
|
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.put('/appearance-prefs', requireUser, async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const theme = parseThemePreference(req.body?.theme)
|
||||||
|
const colorScheme = parseColorSchemePreference(req.body?.colorScheme)
|
||||||
|
if (!theme || !colorScheme) {
|
||||||
|
return res.status(400).json({ error: 'Invalid theme or colorScheme' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefs = await prisma.userAppearancePrefs.upsert({
|
||||||
|
where: { userId: req.userId },
|
||||||
|
create: {
|
||||||
|
userId: req.userId,
|
||||||
|
theme,
|
||||||
|
colorScheme,
|
||||||
|
updatedAt: new Date()
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
theme,
|
||||||
|
colorScheme,
|
||||||
|
updatedAt: new Date()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
theme: prefs.theme,
|
||||||
|
colorScheme: prefs.colorScheme,
|
||||||
|
persisted: true
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error updating appearance prefs:', error)
|
||||||
|
return res.status(500).json({ error: error.message || 'Internal server error' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
router.get('/profile', requireUser, async (req: any, res) => {
|
router.get('/profile', requireUser, async (req: any, res) => {
|
||||||
try {
|
try {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
|
|||||||
Reference in New Issue
Block a user