Compare commits
344 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| faf3b8e3cf | |||
| 74ff8eb16b | |||
| 81d3e3b777 | |||
| 97c5173e63 | |||
| 8b34044481 | |||
| d948325a45 | |||
| 8b8196f6e3 | |||
| 6593b320ee | |||
| 9a931024d6 | |||
| 4dfe2cea4e | |||
| 944f4518e9 | |||
| 0c765f712c | |||
| 676547686b | |||
| 66606c5eca | |||
| a30fac029d | |||
| 796e61f4ea | |||
| 594c65d1a5 | |||
| fafefff29b | |||
| 4fd7f3c6cf | |||
| 262c48a01a | |||
| 9ad3c2cf38 | |||
| 6848390ffa | |||
| 65d2215a35 | |||
| f321e5bbd1 | |||
| d2961b050a | |||
| 6943fd2dc4 | |||
| f332eccf22 | |||
| 9d2a19dbf8 | |||
| e3cd89be5d | |||
| a86da72b04 | |||
| 7d6f381f55 | |||
| 878be33b7c | |||
| 318f5e65da | |||
| 8c6ab59d67 | |||
| a9c3e9ce3e | |||
| 3eaf59e2b3 | |||
| b1e17be7fd | |||
| ac7e7c92d1 | |||
| e10cef4b05 | |||
| 0ec5c51102 | |||
| 57b93b7ce7 | |||
| a4b3515711 | |||
| 41acbaebac | |||
| 6c83cd7d36 | |||
| 9089e1c6f9 | |||
| 1504960d85 | |||
| 599f090895 | |||
| 4eb2b4c517 | |||
| be3b23ed8c | |||
| 697c5781b7 | |||
| 4c36c9160a | |||
| d559a762d2 | |||
| a2180a302c | |||
| cd29115233 | |||
| e4b07ca896 | |||
| f0c3cacb06 | |||
| 5821e20086 | |||
| aff8d1517d | |||
| f4d6b11414 | |||
| 968e81f4fb | |||
| 10835c9def | |||
| cdbc618521 | |||
| f75fe42910 | |||
| 212775ffdc | |||
| c80760db02 | |||
| cd1dd12c15 | |||
| 43cf589613 | |||
| e1cb2754c4 | |||
| 5dedb8fac0 | |||
| 78f1659db4 | |||
| 935c263648 | |||
| 29ac96f892 | |||
| 4d3b7210b3 | |||
| 369bca2ef1 | |||
| 2fcc741f5e | |||
| 27722186d1 | |||
| 5710c74706 | |||
| cd27dfa27d | |||
| c4c7d42de4 | |||
| 71025b3d61 | |||
| f790a6adcc | |||
| de5a46938b | |||
| 16944c1a26 | |||
| fae7b20f90 | |||
| 73e7613a1b | |||
| 6c8aa5af4c | |||
| 9554f4b66e | |||
| 5c77bbfdc3 | |||
| 979b572136 | |||
| f189317dfc | |||
| c54f834311 | |||
| 9d05005bb7 | |||
| 40c4874156 | |||
| 2de0636608 | |||
| 9e7c6f4397 | |||
| 6600ceafce | |||
| d7a497a4a2 | |||
| 4c04086d63 | |||
| 79ce42bec6 | |||
| 72c956162c | |||
| 3080b59dc8 | |||
| d054e42cc0 | |||
| d299fc1d93 | |||
| 6447e95d7d | |||
| 7ec5a1eccc | |||
| 4cf70a3431 | |||
| 6ed8b2a8e7 | |||
| bff00cf0a3 | |||
| 3cab735754 | |||
| 79762a0baf | |||
| 24160b6c5d | |||
| 1326045b25 | |||
| e014e997de | |||
| 1bc449687d | |||
| 35ee705510 | |||
| 9f76c200b0 | |||
| ac627a022f | |||
| 9ae24aa6fb | |||
| 91cf2674f7 | |||
| b7a9df6ae0 | |||
| 7bc3c25ba4 | |||
| e2fa036b9c | |||
| 89f0f52841 | |||
| 6f28ea0b16 | |||
| 975c7a2e40 | |||
| f83d67b527 | |||
| 6c48085904 | |||
| 07de51be22 | |||
| d654aad937 | |||
| dd111ce01f | |||
| 978e132c70 | |||
| 1ecebc5dbb | |||
| caf85ad9eb | |||
| d637fbea16 | |||
| 8e03563f65 | |||
| 3ac4201734 | |||
| 85e641ed39 | |||
| 9bf59280b2 | |||
| aee8f4f3db | |||
| 2b029a26f0 | |||
| 2156aa4bbd | |||
| 5eb4543255 | |||
| fb9bb6754c | |||
| 959afd5a63 | |||
| e3ea45f717 | |||
| 8f57b6ff22 | |||
| 60e1b714b7 | |||
| 1e203bfec1 | |||
| 11420685cf | |||
| c674aac344 | |||
| 9c91a0f1fc | |||
| 2bcbbba626 | |||
| b1500f8361 | |||
| bc7512003e | |||
| eaf126b584 | |||
| a9c712be45 | |||
| b0195601de | |||
| c2b58baa6e | |||
| a85d6e42fc | |||
| 53da4a14a0 | |||
| 2453134c51 | |||
| 671cb2dd9a | |||
| 1d511e0f8c | |||
| 18a68367bc | |||
| 90518372d8 | |||
| 9d22cb61c7 | |||
| bb501ba644 | |||
| f51f088f1e | |||
| 3d2918e0fe | |||
| c5a9b39057 | |||
| 2c8a858c89 | |||
| ee94a5be10 | |||
| 08798dc9b2 | |||
| ddeb69437a | |||
| cdcef2e106 | |||
| 847c73fda9 | |||
| ec11dd8d2b | |||
| 182ea497d8 | |||
| 837bcfe287 | |||
| d261a1e7ca | |||
| 2ebc3e8a44 | |||
| 047a5b1bdb | |||
| 7a7e9d5d28 | |||
| 39cbe707c7 | |||
| bb6e7f5c32 | |||
| ca0daa8f2a | |||
| 2304f95ac1 | |||
| 98c0ed81d4 | |||
| 3504ec97cc | |||
| 4c6c2779f2 | |||
| b6c4e9e7d9 | |||
| 04c6be2b5b | |||
| 9089d017b6 | |||
| f8dc6ace3c | |||
| 18f14d7e0b | |||
| 0edf4a789c | |||
| 4ef56aeb8f | |||
| 3263fbcec3 | |||
| b9ce853059 | |||
| 3d8a505bd9 | |||
| e138752dd3 | |||
| b9c908169b | |||
| e6bde5c525 | |||
| eab7b86c0b | |||
| b86789ae4c | |||
| 2a8ec2fccf | |||
| 60a8533a44 | |||
| c86ac4273c | |||
| 73467f2263 | |||
| e068f083c1 | |||
| f083294db5 | |||
| 8fc15081e2 | |||
| efa0fcf934 | |||
| c1ecdcad9c | |||
| d6c7952af8 | |||
| 3d02f841a0 | |||
| 0caaf681d8 | |||
| 43dc994c4f | |||
| d94502097e | |||
| a36ca2facb | |||
| b7a1085d52 | |||
| 3925c6f822 | |||
| 0b2c1c22c6 | |||
| aa03573e1f | |||
| a0b8664e23 | |||
| 74282f50d0 | |||
| 5b47415d55 | |||
| 039e4e2736 | |||
| 35bfbc1043 | |||
| 6c866dbad5 | |||
| bb667afec8 | |||
| beee33f842 | |||
| 77a7072b77 | |||
| bd1edd89f3 | |||
| ffe6b19818 | |||
| eb1f87f57e | |||
| 13cb03646b | |||
| 1bc0d7fb2a | |||
| 5f3d76b30f | |||
| b48545e943 | |||
| 3749f87c1d | |||
| 2e656dc6b2 | |||
| 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 | |||
| e2e038f2d6 | |||
| 634eb622fd | |||
| 04b822b263 | |||
| ee60d5fda3 | |||
| 3a7d244433 | |||
| 9e03fcda0a | |||
| 34c7d2d65c | |||
| 658bc6c0c9 | |||
| dee2f7b95b | |||
| 4eaf5d7f30 | |||
| 257bca14d1 | |||
| 917fb92d85 | |||
| b48b31580d | |||
| 7f0223c636 | |||
| 68af8c6361 | |||
| ad7e036ab7 | |||
| 12c02f6392 | |||
| 3698c6fbca | |||
| d4538ec06e | |||
| 86cb4d92ec | |||
| b72b20b66c | |||
| 6ad75ff947 | |||
| 75eba362d6 | |||
| afc5a1e200 | |||
| 79a54fdfc2 | |||
| e73c078463 | |||
| 2eb6551200 | |||
| 9baaccf239 | |||
| df53420f3b | |||
| 5271ed90c1 | |||
| a8ba998444 | |||
| 67d169080e |
+45
-5
@@ -1,18 +1,52 @@
|
|||||||
OpenWeatherMapAPIKey=<owm_api_key>
|
OpenWeatherMapAPIKey=<owm_api_key>
|
||||||
|
|
||||||
|
# OpenRouter API (AI travel day summaries — server-side proxy)
|
||||||
|
OpenRouterAPIKey=
|
||||||
|
# Optional model override (default: anthropic/claude-3.5-haiku)
|
||||||
|
# Valid examples: anthropic/claude-3.5-haiku, anthropic/claude-3-haiku, anthropic/claude-haiku-4.5
|
||||||
|
# OpenRouterModel=anthropic/claude-3.5-haiku
|
||||||
|
|
||||||
|
# DeepL API (for scripts/translate-locales.mjs and scripts/translate-flyer.mjs)
|
||||||
|
# Free plan keys use api-free.deepl.com automatically (suffix :fx)
|
||||||
|
DeepLAPIKey=
|
||||||
|
|
||||||
# Passkey configuration (WebAuthn Relying Party ID and Origin)
|
# Passkey configuration (WebAuthn Relying Party ID and Origin)
|
||||||
# For local dev: localhost and http://localhost
|
# For local dev: use localhost (NOT 127.0.0.1 — browsers reject IP addresses for Passkeys)
|
||||||
# For production: e.g. kapteins-daagbok.eu and https://kapteins-daagbok.eu
|
# Production (kapteins-daagbok.eu):
|
||||||
|
# RP_ID=kapteins-daagbok.eu
|
||||||
|
# ORIGIN=https://kapteins-daagbok.eu
|
||||||
|
# Staging (staging.kapteins-daagbok.eu):
|
||||||
|
# RP_ID=staging.kapteins-daagbok.eu
|
||||||
|
# ORIGIN=https://staging.kapteins-daagbok.eu
|
||||||
|
# POSTGRES_DB=daagbox_staging
|
||||||
|
# NTFY_TOPIC=kapteins-daagbok-staging-feedback
|
||||||
RP_ID=localhost
|
RP_ID=localhost
|
||||||
# Must match the frontend URL (Vite dev: http://localhost:5173; Docker: http://localhost)
|
# Must match the frontend URL exactly (Vite dev: http://localhost:5173; Docker: http://localhost)
|
||||||
ORIGIN=http://localhost:5173
|
ORIGIN=http://localhost:5173
|
||||||
# Optional: comma-separated CORS origins (defaults to ORIGIN; dev also allows 127.0.0.1:5173)
|
|
||||||
# CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
# Behind reverse proxy — see docs/deployment/npm-security.md
|
||||||
|
# Docker Compose (NPM → frontend nginx → backend): TRUST_PROXY=1
|
||||||
|
# TRUST_PROXY=1
|
||||||
|
|
||||||
|
# Docker Compose database (required for production deploy)
|
||||||
|
# Generate: openssl rand -hex 24
|
||||||
|
# Rotate on running server: ./scripts/rotate-postgres-password.sh (see docs/deployment/postgres-password.md)
|
||||||
|
# POSTGRES_USER=postgres
|
||||||
|
# POSTGRES_PASSWORD=
|
||||||
|
# POSTGRES_DB=daagbox
|
||||||
|
# Optional: lock Docker Compose to a specific configuration file (e.g. staging or production) on the server:
|
||||||
|
# COMPOSE_FILE=docker-compose.staging.yml
|
||||||
|
# Optional: comma-separated CORS origins (defaults to ORIGIN; 127.0.0.1 may be allowed for CORS but not for login)
|
||||||
|
# CORS_ORIGINS=http://localhost:5173
|
||||||
|
|
||||||
# API session signing (min. 32 chars; required in production)
|
# API session signing (min. 32 chars; required in production)
|
||||||
# Generate: openssl rand -base64 48
|
# Generate: openssl rand -base64 48
|
||||||
SESSION_SECRET=
|
SESSION_SECRET=
|
||||||
|
|
||||||
|
# Admin dashboard access — comma-separated list of User IDs (UUIDs)
|
||||||
|
# Example: ADMIN_USER_IDS=11111111-2222-3333-4444-555555555555,22222222-3333-4444-5555-666666666666
|
||||||
|
ADMIN_USER_IDS=
|
||||||
|
|
||||||
# Web Push (VAPID) — generate with: npx web-push generate-vapid-keys
|
# Web Push (VAPID) — generate with: npx web-push generate-vapid-keys
|
||||||
# Public key may also be set on the client as VITE_VAPID_PUBLIC_KEY
|
# Public key may also be set on the client as VITE_VAPID_PUBLIC_KEY
|
||||||
VAPID_PUBLIC_KEY=
|
VAPID_PUBLIC_KEY=
|
||||||
@@ -24,3 +58,9 @@ VAPID_SUBJECT=mailto:support@kapteins-daagbok.eu
|
|||||||
NTFY_SERVER=https://ntfy.sh
|
NTFY_SERVER=https://ntfy.sh
|
||||||
NTFY_TOPIC=kapteins-daagbok-feedback
|
NTFY_TOPIC=kapteins-daagbok-feedback
|
||||||
NTFY_TOKEN=tk_example_ntfy_access_token
|
NTFY_TOKEN=tk_example_ntfy_access_token
|
||||||
|
|
||||||
|
# Plausible Analytics (frontend container — see docs/plausible-events.md)
|
||||||
|
# Production: PLAUSIBLE_ENABLED=true, data-domain = current hostname (kapteins-daagbok.eu)
|
||||||
|
# Staging: PLAUSIBLE_ENABLED=false (default in docker-compose.staging.yml)
|
||||||
|
PLAUSIBLE_ENABLED=true
|
||||||
|
PLAUSIBLE_HOST=https://plausible.elpatron.me
|
||||||
|
|||||||
@@ -11,3 +11,5 @@ server/dist/
|
|||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
userfeedback/
|
||||||
|
|||||||
@@ -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,21 @@ 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. Qualität & Tests
|
||||||
|
|
||||||
|
Vor jedem Deploy auf [kapteins-daagbok.eu](https://kapteins-daagbok.eu/) (kein externes CI):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run check
|
||||||
|
# oder: ./scripts/predeploy-check.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Einzeln: `npm test` (Client + Server) · `npm run build` · optional `npm run lint` (Client, noch nicht in `check`)
|
||||||
|
|
||||||
|
- **Client:** Vitest für Utils, i18n, Services
|
||||||
|
- **Server:** Smoke-Tests (`/api/health`, Auth-Guards) mit Supertest — siehe `server/src/api.smoke.test.ts`
|
||||||
|
|
||||||
## Docker (produktionsnah)
|
## Docker (produktionsnah)
|
||||||
|
|
||||||
@@ -198,28 +241,49 @@ 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`), `SESSION_SECRET` und für Docker Compose `POSTGRES_PASSWORD`. Für Push die VAPID-Variablen an den Backend-Container durchreichen (`docker-compose.yml` → `backend.environment`). Für Feedback `NTFY_*` setzen.
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen):
|
Produktions-Update auf den Server (konfigurierbar via Umgebungsvariablen). Führt vor dem SSH-Deploy automatisch [`predeploy-check.sh`](scripts/predeploy-check.sh) aus (`npm run check`):
|
||||||
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/update-prod.sh
|
./scripts/update-remotes.sh -dest prod
|
||||||
```
|
```
|
||||||
|
|
||||||
Standard-Ziel: `root@10.0.0.25:/opt/kapteins-daagbok` — per `REMOTE_HOST`, `REMOTE_USER`, `REMOTE_DIR` überschreibbar.
|
Standard-Ziel Prod: `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 `.env` u. a. `POSTGRES_PASSWORD`, `RP_ID`, `ORIGIN` (`https://kapteins-daagbok.eu`), `SESSION_SECRET` (≥ 32 Zeichen), `TRUST_PROXY` (NPM, z. B. `172.16.10.10` oder `1`) und bei Push `VAPID_*` enthalten. Optional `NTFY_*` für Feedback. Nach Schema-Änderungen: `npx prisma db push` im Backend-Container.
|
||||||
|
|
||||||
|
Prod-Deploy legt vor dem Update automatisch ein Server-Backup an (DB, `.env`, Compose, App-Code). Tägliches Cron-Backup und Restore: [docs/deployment/backup.md](docs/deployment/backup.md).
|
||||||
|
|
||||||
|
Hinter **Nginx Proxy Manager**: [docs/deployment/npm-security.md](docs/deployment/npm-security.md).
|
||||||
|
|
||||||
|
### Staging
|
||||||
|
|
||||||
|
Testumgebung unter [staging.kapteins-daagbok.eu](https://staging.kapteins-daagbok.eu) — Deploy ohne Release-Tag:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/update-remotes.sh -dest stage
|
||||||
|
```
|
||||||
|
|
||||||
|
Standard-Ziel Staging: `root@10.0.0.27:/opt/kapteins-daagbok-staging` — per `REMOTE_HOST`, `REMOTE_DIR`, `DEPLOY_BRANCH` überschreibbar. Details: [docs/deployment/staging.md](docs/deployment/staging.md).
|
||||||
|
|
||||||
## Dokumentation
|
## Dokumentation
|
||||||
|
|
||||||
| Dokument | Inhalt |
|
| Dokument | Inhalt |
|
||||||
|----------|--------|
|
|----------|--------|
|
||||||
|
| [docs/deployment/npm-security.md](docs/deployment/npm-security.md) | NPM, TLS, `trust proxy`, Security-Header |
|
||||||
|
| [docs/deployment/predeploy.md](docs/deployment/predeploy.md) | Pre-Deploy-Checks ohne CI |
|
||||||
|
| [docs/deployment/postgres-password.md](docs/deployment/postgres-password.md) | PostgreSQL-Passwort rotieren / App-Rolle |
|
||||||
|
| [docs/deployment/backup.md](docs/deployment/backup.md) | Server-Backup, Crontab, Restore (Prod) |
|
||||||
|
| [docs/deployment/staging.md](docs/deployment/staging.md) | Staging-VM, Deploy, `.env` |
|
||||||
| [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) |
|
||||||
|
|
||||||
|
|||||||
+8
-5
@@ -18,15 +18,18 @@ RUN npm run build
|
|||||||
FROM nginx:1.25-alpine
|
FROM nginx:1.25-alpine
|
||||||
WORKDIR /usr/share/nginx/html
|
WORKDIR /usr/share/nginx/html
|
||||||
|
|
||||||
# Copy custom Nginx configuration
|
RUN apk add --no-cache gettext
|
||||||
COPY client/nginx.conf /etc/nginx/conf.d/default.conf
|
|
||||||
|
COPY client/nginx.conf.template /etc/nginx/templates/default.conf.template
|
||||||
|
COPY client/docker-entrypoint.sh /docker-entrypoint.sh
|
||||||
|
RUN chmod +x /docker-entrypoint.sh
|
||||||
|
|
||||||
# Copy built assets from builder
|
# Copy built assets from builder
|
||||||
COPY --from=builder /app/dist .
|
COPY --from=builder /app/dist .
|
||||||
|
|
||||||
# Expose HTTP port
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
# Health check to verify Nginx is actively running
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=3s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=3s --retries=3 \
|
||||||
CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1
|
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:80/ || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||||
|
|||||||
Executable
+26
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
PLAUSIBLE_ENABLED="${PLAUSIBLE_ENABLED:-true}"
|
||||||
|
PLAUSIBLE_HOST="${PLAUSIBLE_HOST:-https://plausible.elpatron.me}"
|
||||||
|
PLAUSIBLE_HOST="${PLAUSIBLE_HOST%/}"
|
||||||
|
|
||||||
|
case "$(printf '%s' "$PLAUSIBLE_ENABLED" | tr '[:upper:]' '[:lower:]')" in
|
||||||
|
true|1|yes)
|
||||||
|
PLAUSIBLE_ENABLED_JSON=true
|
||||||
|
PLAUSIBLE_CSP=" ${PLAUSIBLE_HOST}"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
PLAUSIBLE_ENABLED_JSON=false
|
||||||
|
PLAUSIBLE_CSP=""
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
export PLAUSIBLE_CSP
|
||||||
|
envsubst '${PLAUSIBLE_CSP}' < /etc/nginx/templates/default.conf.template > /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
cat > /usr/share/nginx/html/runtime-config.json <<EOF
|
||||||
|
{"plausibleEnabled":${PLAUSIBLE_ENABLED_JSON},"plausibleHost":"${PLAUSIBLE_HOST}"}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
exec nginx -g 'daemon off;'
|
||||||
+11
-3
@@ -5,16 +5,25 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content="Kostenloses, werbefreies digitales Yacht-Logbuch mit End-to-End-Verschlüsselung und Passkey-Anmeldung. Reisetage, GPS-Tracks, Crew und Schiffsdaten sicher dokumentieren – auch offline als PWA." />
|
<meta name="description" content="Kostenloses, werbefreies digitales Yacht-Logbuch mit End-to-End-Verschlüsselung und Passkey-Anmeldung. Reisetage, GPS-Tracks, Crew und Schiffsdaten sicher dokumentieren – auch offline als PWA." />
|
||||||
<meta name="keywords" content="Yacht-Logbuch, Schiffstagebuch, Bordlogbuch, Segeln, Passkey, E2E-Verschlüsselung, GPS-Track, maritimes Logbuch, kostenlos, werbefrei, gratis, ohne Werbung" />
|
<meta name="keywords" content="Yacht-Logbuch, Schiffstagebuch, Bordlogbuch, Segeln, Passkey, E2E-Verschlüsselung, GPS-Track, maritimes Logbuch, kostenlos, werbefrei, gratis, ohne Werbung, yacht logbook, sailing log, ad-free" />
|
||||||
<meta name="author" content="Markus F.J. Busche" />
|
<meta name="author" content="Markus F.J. Busche" />
|
||||||
<meta name="robots" content="index, follow" />
|
<meta name="robots" content="index, follow" />
|
||||||
<meta name="application-name" content="Kapteins Daagbok" />
|
<meta name="application-name" content="Kapteins Daagbok" />
|
||||||
<link rel="canonical" href="https://kapteins-daagbok.eu/" />
|
<link rel="canonical" href="https://kapteins-daagbok.eu/" />
|
||||||
|
<link rel="alternate" hreflang="de" href="https://kapteins-daagbok.eu/?lng=de" />
|
||||||
|
<link rel="alternate" hreflang="en" href="https://kapteins-daagbok.eu/?lng=en" />
|
||||||
|
<link rel="alternate" hreflang="da" href="https://kapteins-daagbok.eu/?lng=da" />
|
||||||
|
<link rel="alternate" hreflang="sv" href="https://kapteins-daagbok.eu/?lng=sv" />
|
||||||
|
<link rel="alternate" hreflang="nb" href="https://kapteins-daagbok.eu/?lng=nb" />
|
||||||
|
<link rel="alternate" hreflang="x-default" href="https://kapteins-daagbok.eu/" />
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<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>
|
||||||
|
<script src="/plausible-bootstrap.js"></script>
|
||||||
|
<script src="/bootstrap-watchdog.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" />
|
||||||
@@ -30,7 +39,6 @@
|
|||||||
<meta name="twitter:description" content="Kostenlos und werbefrei: sicheres, E2E-verschlüsseltes Logbuch für Skipper. Reisetage, GPS-Tracks, Crew- und Schiffsdaten – Passkey-Anmeldung und Offline-PWA." />
|
<meta name="twitter:description" content="Kostenlos und werbefrei: sicheres, E2E-verschlüsseltes Logbuch für Skipper. Reisetage, GPS-Tracks, Crew- und Schiffsdaten – Passkey-Anmeldung und Offline-PWA." />
|
||||||
<meta name="twitter:image" content="https://kapteins-daagbok.eu/logo.png" />
|
<meta name="twitter:image" content="https://kapteins-daagbok.eu/logo.png" />
|
||||||
<meta name="twitter:image:alt" content="Kapteins Daagbok Logo" />
|
<meta name="twitter:image:alt" content="Kapteins Daagbok Logo" />
|
||||||
<script defer data-domain="kapteins-daagbok.eu" src="https://plausible.elpatron.me/js/script.tagged-events.js"></script>
|
|
||||||
<title>Kapteins Daagbok – Kostenloses digitales Yacht-Logbuch (werbefrei)</title>
|
<title>Kapteins Daagbok – Kostenloses digitales Yacht-Logbuch (werbefrei)</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+2
-31
@@ -1,31 +1,2 @@
|
|||||||
server {
|
# Generated at container start from PLAUSIBLE_* — see client/nginx.conf.template and docker-entrypoint.sh
|
||||||
listen 80;
|
# Local Docker Compose uses the template via client/Dockerfile entrypoint.
|
||||||
server_name localhost;
|
|
||||||
client_max_body_size 50M;
|
|
||||||
|
|
||||||
# Service worker and app shell must revalidate so PWA updates are detected
|
|
||||||
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest)$ {
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
|
||||||
}
|
|
||||||
|
|
||||||
location = /index.html {
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
add_header Cache-Control "no-cache, must-revalidate";
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html index.htm;
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
}
|
|
||||||
|
|
||||||
location /api/ {
|
|
||||||
proxy_pass http://backend:5000/api/;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection 'upgrade';
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_cache_bypass $http_upgrade;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
client_max_body_size 50M;
|
||||||
|
|
||||||
|
# Security headers (TLS/HSTS at NPM — see docs/deployment/npm-security.md)
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self'${PLAUSIBLE_CSP}; connect-src 'self'${PLAUSIBLE_CSP}; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
|
||||||
|
|
||||||
|
# Service worker and app shell must revalidate so PWA updates are detected
|
||||||
|
location ~* ^/(sw\.js|workbox-.*\.js|manifest\.webmanifest|version\.json)$ {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self'${PLAUSIBLE_CSP}; connect-src 'self'${PLAUSIBLE_CSP}; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /index.html {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
add_header Cache-Control "no-cache, must-revalidate" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=(self)" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self'${PLAUSIBLE_CSP}; connect-src 'self'${PLAUSIBLE_CSP}; img-src 'self' data: blob: https://*.tile.openstreetmap.org; media-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self';" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html index.htm;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:5000/api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+535
-31
@@ -12,11 +12,13 @@
|
|||||||
"bip39": "^3.1.0",
|
"bip39": "^3.1.0",
|
||||||
"dexie": "^4.4.2",
|
"dexie": "^4.4.2",
|
||||||
"dexie-react-hooks": "^4.4.0",
|
"dexie-react-hooks": "^4.4.0",
|
||||||
|
"fflate": "^0.8.3",
|
||||||
"i18next": "^26.3.0",
|
"i18next": "^26.3.0",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^1.16.0",
|
"lucide-react": "^1.16.0",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-i18next": "^17.0.8"
|
"react-i18next": "^17.0.8"
|
||||||
@@ -25,6 +27,7 @@
|
|||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/node": "^24.12.3",
|
"@types/node": "^24.12.3",
|
||||||
|
"@types/qrcode": "^1.5.5",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^4.7.0",
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
@@ -32,12 +35,13 @@
|
|||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.6.0",
|
"globals": "^17.6.0",
|
||||||
|
"happy-dom": "^20.9.0",
|
||||||
"playwright": "^1.51.0",
|
"playwright": "^1.51.0",
|
||||||
"qrcode": "^1.5.4",
|
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"typescript-eslint": "^8.59.2",
|
"typescript-eslint": "^8.59.2",
|
||||||
"vite": "^6.3.5",
|
"vite": "^6.3.5",
|
||||||
"vite-plugin-pwa": "^1.0.1"
|
"vite-plugin-pwa": "^1.0.1",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
@@ -2896,6 +2900,24 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@babel/types": "^7.28.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/chai": {
|
||||||
|
"version": "5.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||||
|
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/deep-eql": "*",
|
||||||
|
"assertion-error": "^2.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/deep-eql": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/esrecurse": {
|
"node_modules/@types/esrecurse": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
||||||
@@ -2950,6 +2972,16 @@
|
|||||||
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
|
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/qrcode": {
|
||||||
|
"version": "1.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||||
|
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/raf": {
|
"node_modules/@types/raf": {
|
||||||
"version": "3.4.3",
|
"version": "3.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
|
||||||
@@ -2991,6 +3023,23 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/whatwg-mimetype": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/ws": {
|
||||||
|
"version": "8.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
|
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.60.0",
|
"version": "8.60.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz",
|
||||||
@@ -3255,6 +3304,131 @@
|
|||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vitest/expect": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/chai": "^5.2.2",
|
||||||
|
"@vitest/spy": "3.2.4",
|
||||||
|
"@vitest/utils": "3.2.4",
|
||||||
|
"chai": "^5.2.0",
|
||||||
|
"tinyrainbow": "^2.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/mocker": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/spy": "3.2.4",
|
||||||
|
"estree-walker": "^3.0.3",
|
||||||
|
"magic-string": "^0.30.17"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"msw": "^2.4.9",
|
||||||
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"msw": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vite": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/mocker/node_modules/estree-walker": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/estree": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/pretty-format": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tinyrainbow": "^2.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/runner": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/utils": "3.2.4",
|
||||||
|
"pathe": "^2.0.3",
|
||||||
|
"strip-literal": "^3.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/snapshot": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/pretty-format": "3.2.4",
|
||||||
|
"magic-string": "^0.30.17",
|
||||||
|
"pathe": "^2.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/spy": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tinyspy": "^4.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/utils": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/pretty-format": "3.2.4",
|
||||||
|
"loupe": "^3.1.4",
|
||||||
|
"tinyrainbow": "^2.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.16.0",
|
"version": "8.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||||
@@ -3299,7 +3473,6 @@
|
|||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -3309,7 +3482,6 @@
|
|||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-convert": "^2.0.1"
|
"color-convert": "^2.0.1"
|
||||||
@@ -3360,6 +3532,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/assertion-error": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/async": {
|
"node_modules/async": {
|
||||||
"version": "3.2.6",
|
"version": "3.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||||
@@ -3541,6 +3723,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cac": {
|
||||||
|
"version": "6.7.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
||||||
|
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/call-bind": {
|
"node_modules/call-bind": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
|
||||||
@@ -3595,7 +3787,6 @@
|
|||||||
"version": "5.3.1",
|
"version": "5.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
@@ -3642,11 +3833,37 @@
|
|||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/chai": {
|
||||||
|
"version": "5.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
|
||||||
|
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"assertion-error": "^2.0.1",
|
||||||
|
"check-error": "^2.1.1",
|
||||||
|
"deep-eql": "^5.0.1",
|
||||||
|
"loupe": "^3.1.0",
|
||||||
|
"pathval": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/check-error": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cliui": {
|
"node_modules/cliui": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"string-width": "^4.2.0",
|
"string-width": "^4.2.0",
|
||||||
@@ -3658,7 +3875,6 @@
|
|||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-name": "~1.1.4"
|
"color-name": "~1.1.4"
|
||||||
@@ -3671,7 +3887,6 @@
|
|||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/commander": {
|
"node_modules/commander": {
|
||||||
@@ -3842,12 +4057,21 @@
|
|||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/deep-eql": {
|
||||||
|
"version": "5.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
|
||||||
|
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/deep-is": {
|
"node_modules/deep-is": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||||
@@ -3921,7 +4145,6 @@
|
|||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/dompurify": {
|
"node_modules/dompurify": {
|
||||||
@@ -3976,9 +4199,21 @@
|
|||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/entities": {
|
||||||
|
"version": "7.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||||
|
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/es-abstract": {
|
"node_modules/es-abstract": {
|
||||||
"version": "1.24.2",
|
"version": "1.24.2",
|
||||||
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
|
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
|
||||||
@@ -4068,6 +4303,13 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-module-lexer": {
|
||||||
|
"version": "1.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
||||||
|
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/es-object-atoms": {
|
"node_modules/es-object-atoms": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||||
@@ -4406,6 +4648,16 @@
|
|||||||
"url": "https://github.com/bgub/eta?sponsor=1"
|
"url": "https://github.com/bgub/eta?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expect-type": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
@@ -4699,7 +4951,6 @@
|
|||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "6.* || 8.* || >= 10.*"
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
@@ -4857,6 +5108,24 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/happy-dom": {
|
||||||
|
"version": "20.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.9.0.tgz",
|
||||||
|
"integrity": "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": ">=20.0.0",
|
||||||
|
"@types/whatwg-mimetype": "^3.0.2",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
|
"entities": "^7.0.1",
|
||||||
|
"whatwg-mimetype": "^3.0.0",
|
||||||
|
"ws": "^8.18.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/has-bigints": {
|
"node_modules/has-bigints": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
|
||||||
@@ -5231,7 +5500,6 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -5710,6 +5978,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/loupe": {
|
||||||
|
"version": "3.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
||||||
|
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lru-cache": {
|
"node_modules/lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||||
@@ -5934,7 +6209,6 @@
|
|||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
@@ -5957,7 +6231,6 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -6007,6 +6280,23 @@
|
|||||||
"node": "20 || >=22"
|
"node": "20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pathe": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/pathval": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/performance-now": {
|
"node_modules/performance-now": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
|
||||||
@@ -6085,7 +6375,6 @@
|
|||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
@@ -6167,7 +6456,6 @@
|
|||||||
"version": "1.5.4",
|
"version": "1.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dijkstrajs": "^1.0.1",
|
"dijkstrajs": "^1.0.1",
|
||||||
@@ -6362,7 +6650,6 @@
|
|||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -6382,7 +6669,6 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
@@ -6554,7 +6840,6 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/set-function-length": {
|
"node_modules/set-function-length": {
|
||||||
@@ -6705,6 +6990,13 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/siginfo": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/signal-exit": {
|
"node_modules/signal-exit": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||||
@@ -6773,6 +7065,13 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/stackback": {
|
||||||
|
"version": "0.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||||
|
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/stackblur-canvas": {
|
"node_modules/stackblur-canvas": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
|
||||||
@@ -6783,6 +7082,13 @@
|
|||||||
"node": ">=0.1.14"
|
"node": ">=0.1.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/std-env": {
|
||||||
|
"version": "3.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
||||||
|
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/stop-iteration-iterator": {
|
"node_modules/stop-iteration-iterator": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
|
||||||
@@ -6801,7 +7107,6 @@
|
|||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"emoji-regex": "^8.0.0",
|
"emoji-regex": "^8.0.0",
|
||||||
@@ -6918,7 +7223,6 @@
|
|||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-regex": "^5.0.1"
|
"ansi-regex": "^5.0.1"
|
||||||
@@ -6937,6 +7241,26 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/strip-literal": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"js-tokens": "^9.0.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/antfu"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/strip-literal/node_modules/js-tokens": {
|
||||||
|
"version": "9.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
|
||||||
|
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/supports-preserve-symlinks-flag": {
|
"node_modules/supports-preserve-symlinks-flag": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
||||||
@@ -7018,6 +7342,20 @@
|
|||||||
"utrie": "^1.0.2"
|
"utrie": "^1.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tinybench": {
|
||||||
|
"version": "2.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
|
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/tinyexec": {
|
||||||
|
"version": "0.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
|
||||||
|
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.16",
|
"version": "0.2.16",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||||
@@ -7035,6 +7373,36 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tinypool": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.0.0 || >=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tinyrainbow": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tinyspy": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tr46": {
|
"node_modules/tr46": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz",
|
||||||
@@ -7439,6 +7807,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vite-node": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cac": "^6.7.14",
|
||||||
|
"debug": "^4.4.1",
|
||||||
|
"es-module-lexer": "^1.7.0",
|
||||||
|
"pathe": "^2.0.3",
|
||||||
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"vite-node": "vite-node.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite-plugin-pwa": {
|
"node_modules/vite-plugin-pwa": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.3.0.tgz",
|
||||||
@@ -7470,6 +7861,79 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vitest": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/chai": "^5.2.2",
|
||||||
|
"@vitest/expect": "3.2.4",
|
||||||
|
"@vitest/mocker": "3.2.4",
|
||||||
|
"@vitest/pretty-format": "^3.2.4",
|
||||||
|
"@vitest/runner": "3.2.4",
|
||||||
|
"@vitest/snapshot": "3.2.4",
|
||||||
|
"@vitest/spy": "3.2.4",
|
||||||
|
"@vitest/utils": "3.2.4",
|
||||||
|
"chai": "^5.2.0",
|
||||||
|
"debug": "^4.4.1",
|
||||||
|
"expect-type": "^1.2.1",
|
||||||
|
"magic-string": "^0.30.17",
|
||||||
|
"pathe": "^2.0.3",
|
||||||
|
"picomatch": "^4.0.2",
|
||||||
|
"std-env": "^3.9.0",
|
||||||
|
"tinybench": "^2.9.0",
|
||||||
|
"tinyexec": "^0.3.2",
|
||||||
|
"tinyglobby": "^0.2.14",
|
||||||
|
"tinypool": "^1.1.1",
|
||||||
|
"tinyrainbow": "^2.0.0",
|
||||||
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
|
||||||
|
"vite-node": "3.2.4",
|
||||||
|
"why-is-node-running": "^2.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"vitest": "vitest.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@edge-runtime/vm": "*",
|
||||||
|
"@types/debug": "^4.1.12",
|
||||||
|
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
|
||||||
|
"@vitest/browser": "3.2.4",
|
||||||
|
"@vitest/ui": "3.2.4",
|
||||||
|
"happy-dom": "*",
|
||||||
|
"jsdom": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@edge-runtime/vm": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/debug": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/browser": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/ui": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"happy-dom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"jsdom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/void-elements": {
|
"node_modules/void-elements": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||||
@@ -7486,6 +7950,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause"
|
"license": "BSD-2-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/whatwg-mimetype": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/whatwg-url": {
|
"node_modules/whatwg-url": {
|
||||||
"version": "7.1.0",
|
"version": "7.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz",
|
||||||
@@ -7585,7 +8059,6 @@
|
|||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/which-typed-array": {
|
"node_modules/which-typed-array": {
|
||||||
@@ -7610,6 +8083,23 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/why-is-node-running": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"siginfo": "^2.0.0",
|
||||||
|
"stackback": "0.0.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"why-is-node-running": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/word-wrap": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||||
@@ -7844,7 +8334,6 @@
|
|||||||
"version": "6.2.0",
|
"version": "6.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-styles": "^4.0.0",
|
"ansi-styles": "^4.0.0",
|
||||||
@@ -7855,11 +8344,32 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||||
|
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/y18n": {
|
"node_modules/y18n": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
@@ -7873,7 +8383,6 @@
|
|||||||
"version": "15.4.1",
|
"version": "15.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cliui": "^6.0.0",
|
"cliui": "^6.0.0",
|
||||||
@@ -7896,7 +8405,6 @@
|
|||||||
"version": "18.1.3",
|
"version": "18.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"camelcase": "^5.0.0",
|
"camelcase": "^5.0.0",
|
||||||
@@ -7910,7 +8418,6 @@
|
|||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"locate-path": "^5.0.0",
|
"locate-path": "^5.0.0",
|
||||||
@@ -7924,7 +8431,6 @@
|
|||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"p-locate": "^4.1.0"
|
"p-locate": "^4.1.0"
|
||||||
@@ -7937,7 +8443,6 @@
|
|||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"p-try": "^2.0.0"
|
"p-try": "^2.0.0"
|
||||||
@@ -7953,7 +8458,6 @@
|
|||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"p-limit": "^2.2.0"
|
"p-limit": "^2.2.0"
|
||||||
|
|||||||
+14
-3
@@ -7,20 +7,29 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
|
"test": "vitest run",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"generate:flyer": "node ../scripts/generate-beta-flyer.mjs",
|
"generate:flyer": "node ../scripts/generate-beta-flyer.mjs",
|
||||||
"generate:flyer:setup": "playwright install chromium"
|
"generate:flyer:png": "node ../scripts/generate-beta-flyer.mjs --png",
|
||||||
|
"generate:flyer:all": "node ../scripts/generate-beta-flyer.mjs --all",
|
||||||
|
"generate:flyer:setup": "playwright install chromium",
|
||||||
|
"generate:sharepic": "node ../scripts/generate-sharepic.mjs",
|
||||||
|
"translate:locales": "node ../scripts/translate-locales.mjs",
|
||||||
|
"translate:flyer": "node ../scripts/translate-flyer.mjs",
|
||||||
|
"validate:i18n": "node ../scripts/validate-i18n-keys.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@simplewebauthn/browser": "^13.3.0",
|
"@simplewebauthn/browser": "^13.3.0",
|
||||||
"bip39": "^3.1.0",
|
"bip39": "^3.1.0",
|
||||||
"dexie": "^4.4.2",
|
"dexie": "^4.4.2",
|
||||||
"dexie-react-hooks": "^4.4.0",
|
"dexie-react-hooks": "^4.4.0",
|
||||||
|
"fflate": "^0.8.3",
|
||||||
"i18next": "^26.3.0",
|
"i18next": "^26.3.0",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^1.16.0",
|
"lucide-react": "^1.16.0",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-i18next": "^17.0.8"
|
"react-i18next": "^17.0.8"
|
||||||
@@ -29,6 +38,7 @@
|
|||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/node": "^24.12.3",
|
"@types/node": "^24.12.3",
|
||||||
|
"@types/qrcode": "^1.5.5",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^4.7.0",
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
@@ -36,12 +46,13 @@
|
|||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.6.0",
|
"globals": "^17.6.0",
|
||||||
|
"happy-dom": "^20.9.0",
|
||||||
"playwright": "^1.51.0",
|
"playwright": "^1.51.0",
|
||||||
"qrcode": "^1.5.4",
|
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"typescript-eslint": "^8.59.2",
|
"typescript-eslint": "^8.59.2",
|
||||||
"vite": "^6.3.5",
|
"vite": "^6.3.5",
|
||||||
"vite-plugin-pwa": "^1.0.1"
|
"vite-plugin-pwa": "^1.0.1",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
}
|
||||||
|
})()
|
||||||
Vendored
+221
@@ -0,0 +1,221 @@
|
|||||||
|
/**
|
||||||
|
* Boot watchdog for production PWAs.
|
||||||
|
* Recovers from white/black screens when stale HTML points to missing JS chunks.
|
||||||
|
* Does not clear caches automatically while offline to protect unsynced data.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var BOOT_TIMEOUT_MS = 12000
|
||||||
|
var ATTEMPT_WINDOW_MS = 120000
|
||||||
|
var ATTEMPT_COUNT_KEY = 'pwa_boot_watchdog_attempt_count'
|
||||||
|
var ATTEMPT_LAST_KEY = 'pwa_boot_watchdog_attempt_last_ts'
|
||||||
|
var PENDING_EVENTS_KEY = 'pwa_boot_pending_events'
|
||||||
|
var MAX_PENDING_EVENTS = 12
|
||||||
|
|
||||||
|
function enqueueEvent(name, props) {
|
||||||
|
try {
|
||||||
|
var current = JSON.parse(sessionStorage.getItem(PENDING_EVENTS_KEY) || '[]')
|
||||||
|
if (!Array.isArray(current)) current = []
|
||||||
|
current.push({ name: name, props: props, ts: Date.now() })
|
||||||
|
if (current.length > MAX_PENDING_EVENTS) {
|
||||||
|
current = current.slice(current.length - MAX_PENDING_EVENTS)
|
||||||
|
}
|
||||||
|
sessionStorage.setItem(PENDING_EVENTS_KEY, JSON.stringify(current))
|
||||||
|
} catch (_) {
|
||||||
|
/* ignore analytics queue errors */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function emit(name, props) {
|
||||||
|
if (typeof window.plausible === 'function') {
|
||||||
|
if (props && Object.keys(props).length > 0) {
|
||||||
|
window.plausible(name, { props: props })
|
||||||
|
} else {
|
||||||
|
window.plausible(name)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
enqueueEvent(name, props)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasBootstrapped() {
|
||||||
|
return window.__KDB_APP_BOOTSTRAPPED === true
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetAttempts() {
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem(ATTEMPT_COUNT_KEY)
|
||||||
|
sessionStorage.removeItem(ATTEMPT_LAST_KEY)
|
||||||
|
} catch (_) {
|
||||||
|
/* ignore storage errors */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextAttempt() {
|
||||||
|
try {
|
||||||
|
var now = Date.now()
|
||||||
|
var last = Number(sessionStorage.getItem(ATTEMPT_LAST_KEY) || '0')
|
||||||
|
var count = Number(sessionStorage.getItem(ATTEMPT_COUNT_KEY) || '0')
|
||||||
|
if (now - last > ATTEMPT_WINDOW_MS) {
|
||||||
|
count = 0
|
||||||
|
}
|
||||||
|
count += 1
|
||||||
|
sessionStorage.setItem(ATTEMPT_COUNT_KEY, String(count))
|
||||||
|
sessionStorage.setItem(ATTEMPT_LAST_KEY, String(now))
|
||||||
|
return count
|
||||||
|
} catch (_) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRecoveryUrl(reason) {
|
||||||
|
try {
|
||||||
|
var url = new URL(location.href)
|
||||||
|
url.searchParams.set('boot_recover', reason)
|
||||||
|
url.searchParams.set('_', String(Date.now()))
|
||||||
|
return url.toString()
|
||||||
|
} catch (_) {
|
||||||
|
return location.href
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearServiceWorkerCaches() {
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
try {
|
||||||
|
var registrations = await navigator.serviceWorker.getRegistrations()
|
||||||
|
await Promise.all(
|
||||||
|
registrations.map(function (registration) {
|
||||||
|
return registration.unregister()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
} catch (_) {
|
||||||
|
/* ignore SW cleanup errors */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ('caches' in window) {
|
||||||
|
try {
|
||||||
|
var keys = await caches.keys()
|
||||||
|
await Promise.all(
|
||||||
|
keys.map(function (key) {
|
||||||
|
return caches.delete(key)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
} catch (_) {
|
||||||
|
/* ignore cache cleanup errors */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFallback(isOffline) {
|
||||||
|
var root = document.getElementById('root')
|
||||||
|
if (!root) return
|
||||||
|
|
||||||
|
root.innerHTML =
|
||||||
|
'<div class="auth-screen">' +
|
||||||
|
'<div class="auth-card glass" role="alert" style="max-width:460px">' +
|
||||||
|
'<h2 style="margin-top:0">Kapteins Daagbok</h2>' +
|
||||||
|
'<p style="color:var(--app-text-muted);line-height:1.5;margin-bottom:8px">' +
|
||||||
|
(isOffline
|
||||||
|
? 'Die App konnte offline nicht sauber starten. Deine lokalen, nicht synchronisierten Daten bleiben erhalten.'
|
||||||
|
: 'Die App konnte nicht sauber starten. Deine lokalen, nicht synchronisierten Daten bleiben erhalten.') +
|
||||||
|
'</p>' +
|
||||||
|
'<p style="color:var(--app-text-muted);line-height:1.5;margin-top:0">' +
|
||||||
|
(isOffline
|
||||||
|
? 'Bitte neu laden. Wenn wieder Netz verfügbar ist, kann die App-Engine automatisch repariert werden.'
|
||||||
|
: 'Du kannst jetzt eine App-Reparatur ausfuehren, ohne IndexedDB-Logbuchdaten zu loeschen.') +
|
||||||
|
'</p>' +
|
||||||
|
'<button type="button" class="btn primary" id="boot-reload-btn" style="width:100%">' +
|
||||||
|
'Neu laden' +
|
||||||
|
'</button>' +
|
||||||
|
(!isOffline
|
||||||
|
? '<button type="button" class="btn secondary" id="boot-repair-btn" style="width:100%;margin-top:12px">' +
|
||||||
|
'App-Reparatur (Cache + Service Worker)' +
|
||||||
|
'</button>'
|
||||||
|
: '') +
|
||||||
|
'</div>' +
|
||||||
|
'</div>'
|
||||||
|
|
||||||
|
var reloadBtn = document.getElementById('boot-reload-btn')
|
||||||
|
if (reloadBtn) {
|
||||||
|
reloadBtn.addEventListener('click', function () {
|
||||||
|
location.replace(createRecoveryUrl('retry'))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var repairBtn = document.getElementById('boot-repair-btn')
|
||||||
|
if (repairBtn) {
|
||||||
|
repairBtn.addEventListener('click', function () {
|
||||||
|
emit('PWA Boot Watchdog Manual Repair', {
|
||||||
|
attempt: Number(sessionStorage.getItem(ATTEMPT_COUNT_KEY) || '0'),
|
||||||
|
online: navigator.onLine
|
||||||
|
})
|
||||||
|
Promise.resolve()
|
||||||
|
.then(clearServiceWorkerCaches)
|
||||||
|
.finally(function () {
|
||||||
|
resetAttempts()
|
||||||
|
location.replace(createRecoveryUrl('manual-hard-recovery'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runWatchdog() {
|
||||||
|
window.setTimeout(function () {
|
||||||
|
if (hasBootstrapped()) {
|
||||||
|
resetAttempts()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var attempt = nextAttempt()
|
||||||
|
var online = navigator.onLine
|
||||||
|
|
||||||
|
if (attempt === 1) {
|
||||||
|
emit('PWA Boot Watchdog Soft', {
|
||||||
|
attempt: attempt,
|
||||||
|
online: online,
|
||||||
|
reason: online ? 'soft-reload' : 'offline-retry'
|
||||||
|
})
|
||||||
|
Promise.resolve()
|
||||||
|
.then(function () {
|
||||||
|
if ('serviceWorker' in navigator && navigator.serviceWorker.getRegistration) {
|
||||||
|
return navigator.serviceWorker.getRegistration().then(function (registration) {
|
||||||
|
if (registration) {
|
||||||
|
return registration.update().catch(function () {})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(function () {
|
||||||
|
location.replace(createRecoveryUrl(online ? 'soft-reload' : 'offline-retry'))
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt === 2 && online) {
|
||||||
|
emit('PWA Boot Watchdog Hard', {
|
||||||
|
attempt: attempt,
|
||||||
|
online: online,
|
||||||
|
reason: 'hard-recovery'
|
||||||
|
})
|
||||||
|
Promise.resolve()
|
||||||
|
.then(clearServiceWorkerCaches)
|
||||||
|
.finally(function () {
|
||||||
|
location.replace(createRecoveryUrl('hard-recovery'))
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('PWA Boot Watchdog Fallback', {
|
||||||
|
attempt: attempt,
|
||||||
|
online: online,
|
||||||
|
reason: online ? 'retries-exhausted' : 'offline-retries-exhausted'
|
||||||
|
})
|
||||||
|
renderFallback(!online)
|
||||||
|
}, BOOT_TIMEOUT_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
runWatchdog()
|
||||||
|
})()
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 292 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Loads Plausible when enabled via /runtime-config.json (from .env in Docker / Vite dev).
|
||||||
|
* data-domain is always the current hostname (prod vs staging).
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
function load(cfg) {
|
||||||
|
if (!cfg || !cfg.plausibleEnabled || !cfg.plausibleHost) return
|
||||||
|
var host = String(cfg.plausibleHost).replace(/\/$/, '')
|
||||||
|
if (!host) return
|
||||||
|
var s = document.createElement('script')
|
||||||
|
s.defer = true
|
||||||
|
s.dataset.domain = window.location.hostname
|
||||||
|
s.src = host + '/js/script.tagged-events.js'
|
||||||
|
document.head.appendChild(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch('/runtime-config.json', { cache: 'no-store' })
|
||||||
|
.then(function (r) {
|
||||||
|
return r.ok ? r.json() : null
|
||||||
|
})
|
||||||
|
.then(load)
|
||||||
|
.catch(function () {
|
||||||
|
/* analytics optional */
|
||||||
|
})
|
||||||
|
})()
|
||||||
+3020
-80
File diff suppressed because it is too large
Load Diff
+354
-60
@@ -1,10 +1,14 @@
|
|||||||
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 LogbookDashboard from './components/LogbookDashboard.tsx'
|
import LogbookDashboard from './components/LogbookDashboard.tsx'
|
||||||
import VesselForm from './components/VesselForm.tsx'
|
import LogbookVesselPicker from './components/LogbookVesselPicker.tsx'
|
||||||
import CrewForm from './components/CrewForm.tsx'
|
import LogbookCrewPicker from './components/LogbookCrewPicker.tsx'
|
||||||
|
import { migrateLegacyCrewToPoolIfNeeded } from './services/crewMigration.js'
|
||||||
|
import { migrateLegacyYachtsToPoolIfNeeded } from './services/vesselMigration.js'
|
||||||
|
import { syncVesselPool } from './services/vesselPoolSync.js'
|
||||||
|
import { syncPersonPool } from './services/personPoolSync.js'
|
||||||
// Compass Deviation Table — für Freizeit-Skipper vorerst deaktiviert (Komponente bleibt erhalten)
|
// Compass Deviation Table — für Freizeit-Skipper vorerst deaktiviert (Komponente bleibt erhalten)
|
||||||
// import DeviationForm from './components/DeviationForm.tsx'
|
// import DeviationForm from './components/DeviationForm.tsx'
|
||||||
import LogEntriesList from './components/LogEntriesList.tsx'
|
import LogEntriesList from './components/LogEntriesList.tsx'
|
||||||
@@ -14,7 +18,13 @@ import InvitationAcceptance from './components/InvitationAcceptance.tsx'
|
|||||||
import AppTourOverlay from './components/AppTourOverlay.tsx'
|
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 { getActiveMasterKey, logoutUser, checkServerSession } from './services/auth.js'
|
import {
|
||||||
|
logoutUser,
|
||||||
|
checkServerSession,
|
||||||
|
hasUnlockedLocalSession,
|
||||||
|
persistSessionUserId
|
||||||
|
} from './services/auth.js'
|
||||||
|
import AppErrorBoundary from './components/AppErrorBoundary.tsx'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from './services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from './services/analytics.js'
|
||||||
import {
|
import {
|
||||||
applyAppearanceToDocument,
|
applyAppearanceToDocument,
|
||||||
@@ -22,9 +32,11 @@ 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'
|
||||||
|
import AdminDashboard from './admin/AdminDashboard.tsx'
|
||||||
import PwaInstallPrompt from './components/PwaInstallPrompt.tsx'
|
import PwaInstallPrompt from './components/PwaInstallPrompt.tsx'
|
||||||
import PwaUpdatePrompt from './components/PwaUpdatePrompt.tsx'
|
import PwaUpdatePrompt from './components/PwaUpdatePrompt.tsx'
|
||||||
import AppFooter from './components/AppFooter.tsx'
|
import AppFooter from './components/AppFooter.tsx'
|
||||||
@@ -34,23 +46,29 @@ import { db } from './services/db.js'
|
|||||||
import { getLogbookAccess } from './services/logbookAccess.js'
|
import { getLogbookAccess } from './services/logbookAccess.js'
|
||||||
import type { LogbookAccessRole } from './services/logbook.js'
|
import type { LogbookAccessRole } from './services/logbook.js'
|
||||||
import { useLiveQuery } from 'dexie-react-hooks'
|
import { useLiveQuery } from 'dexie-react-hooks'
|
||||||
import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, Languages, BarChart2 } from 'lucide-react'
|
import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, BarChart2 } from 'lucide-react'
|
||||||
import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
|
import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
|
||||||
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
|
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
|
||||||
|
import ProfileHeaderButton from './components/ProfileHeaderButton.tsx'
|
||||||
|
import AdminHeaderButton from './components/AdminHeaderButton.tsx'
|
||||||
|
import { checkAdminAccess } from './services/adminApi.js'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import LanguageDropdown from './components/LanguageDropdown.tsx'
|
||||||
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'
|
||||||
import { ensurePushSubscriptionIfEnabled } from './services/pushNotifications.js'
|
import { ensurePushSubscriptionIfEnabled } from './services/pushNotifications.js'
|
||||||
|
import SyncConflictBanner from './components/SyncConflictBanner.tsx'
|
||||||
|
import { requestPersistentStorage } from './utils/storagePersist.js'
|
||||||
|
|
||||||
const PENDING_PUSH_LOGBOOK_KEY = 'pending_push_logbook_id'
|
const PENDING_PUSH_LOGBOOK_KEY = 'pending_push_logbook_id'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { t, i18n } = useTranslation()
|
const { t } = 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)
|
||||||
@@ -61,6 +79,14 @@ function App() {
|
|||||||
const [online, setOnline] = useState(navigator.onLine)
|
const [online, setOnline] = useState(navigator.onLine)
|
||||||
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 [storagePersistHint, setStoragePersistHint] = 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)
|
||||||
@@ -69,6 +95,10 @@ function App() {
|
|||||||
|
|
||||||
// Public demo mode (no account required)
|
// Public demo mode (no account required)
|
||||||
const [isDemoMode, setIsDemoMode] = useState(() => window.location.pathname === '/demo')
|
const [isDemoMode, setIsDemoMode] = useState(() => window.location.pathname === '/demo')
|
||||||
|
const [isAdminRoute, setIsAdminRoute] = useState(() => window.location.pathname.startsWith('/admin'))
|
||||||
|
const [isAdminUser, setIsAdminUser] = useState(false)
|
||||||
|
const [sessionChecked, setSessionChecked] = useState(false)
|
||||||
|
const [serverSessionActive, setServerSessionActive] = useState(false)
|
||||||
|
|
||||||
const syncQueueCount = useLiveQuery(
|
const syncQueueCount = useLiveQuery(
|
||||||
() => activeLogbookId ? db.syncQueue.where({ logbookId: activeLogbookId }).count() : db.syncQueue.count(),
|
() => activeLogbookId ? db.syncQueue.where({ logbookId: activeLogbookId }).count() : db.syncQueue.count(),
|
||||||
@@ -80,7 +110,7 @@ function App() {
|
|||||||
[activeLogbookId]
|
[activeLogbookId]
|
||||||
)
|
)
|
||||||
|
|
||||||
const [activeAccessRole, setActiveAccessRole] = useState<LogbookAccessRole | null>('OWNER')
|
const [activeAccessRole, setActiveAccessRole] = useState<LogbookAccessRole | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeLogbookId) {
|
if (!activeLogbookId) {
|
||||||
@@ -137,6 +167,24 @@ function App() {
|
|||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const refreshAdminAccess = useCallback(async () => {
|
||||||
|
const isAdmin = await checkAdminAccess()
|
||||||
|
setIsAdminUser(isAdmin)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
setIsAdminUser(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const userId = localStorage.getItem('active_userid')
|
||||||
|
if (!userId) return
|
||||||
|
void syncAppearancePrefs(userId)
|
||||||
|
void migrateLegacyCrewToPoolIfNeeded().then(() => syncPersonPool())
|
||||||
|
void migrateLegacyYachtsToPoolIfNeeded().then(() => syncVesselPool())
|
||||||
|
void refreshAdminAccess()
|
||||||
|
}, [isAuthenticated, refreshAdminAccess])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleOnline = () => {
|
const handleOnline = () => {
|
||||||
setOnline(true)
|
setOnline(true)
|
||||||
@@ -167,6 +215,13 @@ function App() {
|
|||||||
const hashParams = new URLSearchParams(window.location.hash.substring(1))
|
const hashParams = new URLSearchParams(window.location.hash.substring(1))
|
||||||
const path = window.location.pathname
|
const path = window.location.pathname
|
||||||
|
|
||||||
|
if (path.startsWith('/admin')) {
|
||||||
|
setIsAdminRoute(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsAdminRoute(false)
|
||||||
|
|
||||||
if (path === '/demo') {
|
if (path === '/demo') {
|
||||||
setIsDemoMode(true)
|
setIsDemoMode(true)
|
||||||
setIsViewerMode(false)
|
setIsViewerMode(false)
|
||||||
@@ -206,6 +261,55 @@ function App() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const clearAuthenticatedAppState = useCallback(() => {
|
||||||
|
setIsAuthenticated(false)
|
||||||
|
setIsAdminUser(false)
|
||||||
|
setActiveLogbookId(null)
|
||||||
|
setActiveLogbookTitle(null)
|
||||||
|
setShowUserProfile(false)
|
||||||
|
setTourSelectedEntryId(null)
|
||||||
|
setDemoHighlightEntryId(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/** After PWA/bfcache resume, React state may still say "logged in" while the master key is gone. */
|
||||||
|
const enforceUnlockedSession = useCallback(() => {
|
||||||
|
if (isViewerMode || isDemoMode || isAcceptingInvite || isAdminRoute) return
|
||||||
|
// Require full local session (incl. userId) so API calls are not left headless.
|
||||||
|
if (isAuthenticated && !hasUnlockedLocalSession()) {
|
||||||
|
clearAuthenticatedAppState()
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
isAuthenticated,
|
||||||
|
isViewerMode,
|
||||||
|
isDemoMode,
|
||||||
|
isAcceptingInvite,
|
||||||
|
isAdminRoute,
|
||||||
|
clearAuthenticatedAppState
|
||||||
|
])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
enforceUnlockedSession()
|
||||||
|
}, [enforceUnlockedSession])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onPageShow = (event: PageTransitionEvent) => {
|
||||||
|
if (event.persisted) {
|
||||||
|
enforceUnlockedSession()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const onVisibility = () => {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
enforceUnlockedSession()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('pageshow', onPageShow)
|
||||||
|
document.addEventListener('visibilitychange', onVisibility)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('pageshow', onPageShow)
|
||||||
|
document.removeEventListener('visibilitychange', onVisibility)
|
||||||
|
}
|
||||||
|
}, [enforceUnlockedSession])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
||||||
@@ -214,13 +318,14 @@ function App() {
|
|||||||
const session = await checkServerSession()
|
const session = await checkServerSession()
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
|
|
||||||
if (session.authenticated && session.userId) {
|
setServerSessionActive(session.authenticated)
|
||||||
localStorage.setItem('active_userid', session.userId)
|
|
||||||
|
if (session.authenticated) {
|
||||||
|
persistSessionUserId(session.userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const savedUser = localStorage.getItem('active_username')
|
// Cookie alone is insufficient — need in-memory master key, username, and userId for API.
|
||||||
const key = getActiveMasterKey()
|
if (session.authenticated && hasUnlockedLocalSession()) {
|
||||||
if (session.authenticated && savedUser && key) {
|
|
||||||
setIsAuthenticated(true)
|
setIsAuthenticated(true)
|
||||||
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')
|
||||||
@@ -229,17 +334,22 @@ function App() {
|
|||||||
setActiveLogbookTitle(savedLogbookTitle)
|
setActiveLogbookTitle(savedLogbookTitle)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 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)
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setSessionChecked(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [])
|
}, [clearAuthenticatedAppState])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
syncRouteFromLocation()
|
syncRouteFromLocation()
|
||||||
@@ -254,28 +364,74 @@ function App() {
|
|||||||
setIsAcceptingInvite(false)
|
setIsAcceptingInvite(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
const openAdmin = useCallback(() => {
|
||||||
registerNavigation({
|
window.history.pushState({}, document.title, '/admin')
|
||||||
setActiveTab,
|
setIsAdminRoute(true)
|
||||||
setSelectedEntryId: setTourSelectedEntryId,
|
setIsDemoMode(false)
|
||||||
setFeedbackOpen: setTourFeedbackOpen
|
setIsViewerMode(false)
|
||||||
})
|
setIsAcceptingInvite(false)
|
||||||
}, [registerNavigation])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
const selectLogbook = useCallback((id: string, title: string) => {
|
||||||
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) => {
|
||||||
@@ -291,7 +447,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
selectLogbook(logbookId, `${logbookId.slice(0, 8)}…`)
|
selectLogbook(logbookId, `${logbookId.slice(0, 8)}…`)
|
||||||
},
|
},
|
||||||
[]
|
[selectLogbook]
|
||||||
)
|
)
|
||||||
|
|
||||||
const consumePendingPushLogbook = useCallback(() => {
|
const consumePendingPushLogbook = useCallback(() => {
|
||||||
@@ -320,10 +476,19 @@ function App() {
|
|||||||
return () => navigator.serviceWorker.removeEventListener('message', onSwMessage)
|
return () => navigator.serviceWorker.removeEventListener('message', onSwMessage)
|
||||||
}, [isAuthenticated, openLogbookById])
|
}, [isAuthenticated, openLogbookById])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) return
|
||||||
|
if (sessionStorage.getItem('storage_persist_hint_dismissed')) return
|
||||||
|
void requestPersistentStorage().then(({ persisted, supported }) => {
|
||||||
|
if (supported && !persisted) setStoragePersistHint(true)
|
||||||
|
})
|
||||||
|
}, [isAuthenticated])
|
||||||
|
|
||||||
const handleAuthenticated = async () => {
|
const handleAuthenticated = async () => {
|
||||||
setIsAuthenticated(true)
|
setIsAuthenticated(true)
|
||||||
trackPlausibleEvent(PlausibleEvents.LOGGED_IN)
|
trackPlausibleEvent(PlausibleEvents.LOGGED_IN)
|
||||||
void ensurePushSubscriptionIfEnabled()
|
void ensurePushSubscriptionIfEnabled()
|
||||||
|
void requestPersistentStorage()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const demo = await seedDemoLogbookIfNeeded()
|
const demo = await seedDemoLogbookIfNeeded()
|
||||||
@@ -343,8 +508,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()
|
||||||
}
|
}
|
||||||
@@ -359,8 +536,10 @@ function App() {
|
|||||||
if (!(await confirmLeave())) return
|
if (!(await confirmLeave())) return
|
||||||
void logoutUser()
|
void logoutUser()
|
||||||
setIsAuthenticated(false)
|
setIsAuthenticated(false)
|
||||||
|
setIsAdminUser(false)
|
||||||
setActiveLogbookId(null)
|
setActiveLogbookId(null)
|
||||||
setActiveLogbookTitle(null)
|
setActiveLogbookTitle(null)
|
||||||
|
setShowUserProfile(false)
|
||||||
setTourSelectedEntryId(null)
|
setTourSelectedEntryId(null)
|
||||||
setDemoHighlightEntryId(null)
|
setDemoHighlightEntryId(null)
|
||||||
localStorage.removeItem('active_logbook_id')
|
localStorage.removeItem('active_logbook_id')
|
||||||
@@ -376,16 +555,33 @@ function App() {
|
|||||||
localStorage.removeItem('active_logbook_title')
|
localStorage.removeItem('active_logbook_title')
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleLanguage = () => {
|
|
||||||
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
|
|
||||||
i18n.changeLanguage(nextLang)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleExitDemo = () => {
|
const handleExitDemo = () => {
|
||||||
window.history.replaceState({}, document.title, '/')
|
window.history.replaceState({}, document.title, '/')
|
||||||
syncRouteFromLocation()
|
syncRouteFromLocation()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleBackFromAdmin = () => {
|
||||||
|
window.history.replaceState({}, document.title, '/')
|
||||||
|
setIsAdminRoute(false)
|
||||||
|
syncRouteFromLocation()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAdminRoute) {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<div className="auth-screen">
|
||||||
|
<AuthOnboarding onAuthenticated={handleAuthenticated} onOpenDemo={openDemo} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'contents' }}>
|
||||||
|
<AdminDashboard onBack={handleBackFromAdmin} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (isDemoMode) {
|
if (isDemoMode) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'contents' }}>
|
<div style={{ display: 'contents' }}>
|
||||||
@@ -426,7 +622,17 @@ function App() {
|
|||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return (
|
return (
|
||||||
<div className="auth-screen">
|
<div className="auth-screen">
|
||||||
<AuthOnboarding onAuthenticated={handleAuthenticated} onOpenDemo={openDemo} />
|
{!sessionChecked ? (
|
||||||
|
<div className="auth-card glass">
|
||||||
|
<p className="dashboard-status-msg">{t('auth.restore_checking')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<AuthOnboarding
|
||||||
|
restoreSession={serverSessionActive}
|
||||||
|
onAuthenticated={handleAuthenticated}
|
||||||
|
onOpenDemo={openDemo}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -436,7 +642,20 @@ function App() {
|
|||||||
const logbookReadOnly =
|
const logbookReadOnly =
|
||||||
activeLogbookRecord?.isShared === 1 && activeAccessRole === 'READ'
|
activeLogbookRecord?.isShared === 1 && activeAccessRole === 'READ'
|
||||||
const isLogbookOwner =
|
const isLogbookOwner =
|
||||||
activeAccessRole === 'OWNER' || activeLogbookRecord?.isShared !== 1
|
activeAccessRole === 'OWNER' ||
|
||||||
|
(activeLogbookRecord != null && activeLogbookRecord.isShared !== 1)
|
||||||
|
|
||||||
|
if (showUserProfile) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'contents' }}>
|
||||||
|
{pwaInstallBanner}
|
||||||
|
<UserProfilePage
|
||||||
|
onBack={() => setShowUserProfile(false)}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (!activeLogbookId) {
|
if (!activeLogbookId) {
|
||||||
return (
|
return (
|
||||||
@@ -445,6 +664,8 @@ function App() {
|
|||||||
<LogbookDashboard
|
<LogbookDashboard
|
||||||
onSelectLogbook={selectLogbook}
|
onSelectLogbook={selectLogbook}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
|
onOpenProfile={() => setShowUserProfile(true)}
|
||||||
|
onOpenAdmin={isAdminUser ? openAdmin : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -473,7 +694,7 @@ function App() {
|
|||||||
<p className="app-subtitle">
|
<p className="app-subtitle">
|
||||||
{activeAccessRole && activeAccessRole !== 'OWNER'
|
{activeAccessRole && activeAccessRole !== 'OWNER'
|
||||||
? t('dashboard.section_shared_hint')
|
? t('dashboard.section_shared_hint')
|
||||||
: `${t('app.name')} / ${activeLogbookId?.substring(0, 8)}...`}
|
: t('app.tagline')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -490,10 +711,11 @@ function App() {
|
|||||||
{online ? <Wifi size={18} /> : <WifiOff size={18} />}
|
{online ? <Wifi size={18} /> : <WifiOff size={18} />}
|
||||||
<span>{online ? 'Online' : t('sync.status_offline')}</span>
|
<span>{online ? 'Online' : t('sync.status_offline')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<LanguageDropdown variant="icon" align="right" />
|
||||||
|
|
||||||
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
|
{isAdminUser && <AdminHeaderButton onClick={openAdmin} />}
|
||||||
<Languages size={18} />
|
|
||||||
</button>
|
<ProfileHeaderButton onClick={() => setShowUserProfile(true)} />
|
||||||
|
|
||||||
<DisclaimerHeaderButton />
|
<DisclaimerHeaderButton />
|
||||||
|
|
||||||
@@ -511,10 +733,28 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<SyncConflictBanner logbookId={activeLogbookId} />
|
||||||
|
|
||||||
|
{storagePersistHint && (
|
||||||
|
<div className="storage-persist-hint glass" role="status">
|
||||||
|
<p>{t('pwa.storage_persist_hint')}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={() => {
|
||||||
|
sessionStorage.setItem('storage_persist_hint_dismissed', '1')
|
||||||
|
setStoragePersistHint(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('pwa.later')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Active Workspace */}
|
{/* Active Workspace */}
|
||||||
<div className="app-body">
|
<div className="app-body">
|
||||||
{/* Navigation Sidebar */}
|
{/* Navigation Sidebar */}
|
||||||
<aside className="app-sidebar">
|
<aside className="app-sidebar" aria-label={t('nav.dashboard')}>
|
||||||
<button
|
<button
|
||||||
className={`sidebar-btn ${activeTab === 'logs' ? 'active' : ''}`}
|
className={`sidebar-btn ${activeTab === 'logs' ? 'active' : ''}`}
|
||||||
onClick={() => void handleTabChange('logs')}
|
onClick={() => void handleTabChange('logs')}
|
||||||
@@ -536,7 +776,7 @@ function App() {
|
|||||||
<button
|
<button
|
||||||
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
|
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
|
||||||
onClick={() => void handleTabChange('crew')}
|
onClick={() => void handleTabChange('crew')}
|
||||||
data-tour="nav-crew"
|
data-tour="nav-logbook-crew"
|
||||||
>
|
>
|
||||||
<Users size={18} />
|
<Users size={18} />
|
||||||
{t('nav.crew')}
|
{t('nav.crew')}
|
||||||
@@ -583,14 +823,19 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'vessel' && (
|
{activeTab === 'vessel' && (
|
||||||
<VesselForm logbookId={activeLogbookId} readOnly={logbookReadOnly || !isLogbookOwner} />
|
<LogbookVesselPicker
|
||||||
|
logbookId={activeLogbookId}
|
||||||
|
readOnly={logbookReadOnly || !isLogbookOwner}
|
||||||
|
selectionOnly={!isLogbookOwner && activeLogbookRecord?.isShared === 1}
|
||||||
|
onOpenProfile={isLogbookOwner ? () => setShowUserProfile(true) : undefined}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'crew' && (
|
{activeTab === 'crew' && (
|
||||||
<CrewForm
|
<LogbookCrewPicker
|
||||||
logbookId={activeLogbookId}
|
logbookId={activeLogbookId}
|
||||||
readOnly={logbookReadOnly}
|
readOnly={logbookReadOnly}
|
||||||
skipperReadOnly={!isLogbookOwner}
|
selectionOnly={!isLogbookOwner && activeLogbookRecord?.isShared === 1}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -611,6 +856,53 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<nav className="app-bottom-nav" aria-label={t('nav.dashboard')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`bottom-nav-btn ${activeTab === 'logs' ? 'active' : ''}`}
|
||||||
|
onClick={() => void handleTabChange('logs')}
|
||||||
|
data-tour="nav-logs"
|
||||||
|
>
|
||||||
|
<FileText size={20} />
|
||||||
|
<span>{t('nav.logs')}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`bottom-nav-btn ${activeTab === 'vessel' ? 'active' : ''}`}
|
||||||
|
onClick={() => void handleTabChange('vessel')}
|
||||||
|
data-tour="nav-vessel"
|
||||||
|
>
|
||||||
|
<Ship size={20} />
|
||||||
|
<span>{t('nav.vessel')}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`bottom-nav-btn ${activeTab === 'crew' ? 'active' : ''}`}
|
||||||
|
onClick={() => void handleTabChange('crew')}
|
||||||
|
data-tour="nav-logbook-crew"
|
||||||
|
>
|
||||||
|
<Users size={20} />
|
||||||
|
<span>{t('nav.crew')}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`bottom-nav-btn ${activeTab === 'stats' ? 'active' : ''}`}
|
||||||
|
onClick={() => void handleTabChange('stats')}
|
||||||
|
data-tour="nav-stats"
|
||||||
|
>
|
||||||
|
<BarChart2 size={20} />
|
||||||
|
<span>{t('nav.stats')}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`bottom-nav-btn ${activeTab === 'settings' ? 'active' : ''}`}
|
||||||
|
onClick={() => void handleTabChange('settings')}
|
||||||
|
>
|
||||||
|
<Settings size={20} />
|
||||||
|
<span>{t('nav.settings')}</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -619,15 +911,17 @@ function App() {
|
|||||||
|
|
||||||
export default function AppWrapper() {
|
export default function AppWrapper() {
|
||||||
return (
|
return (
|
||||||
<DialogProvider>
|
<AppErrorBoundary>
|
||||||
<UnsavedChangesProvider>
|
<DialogProvider>
|
||||||
<AppTourProvider>
|
<UnsavedChangesProvider>
|
||||||
<PwaUpdatePrompt />
|
<AppTourProvider>
|
||||||
<App />
|
<PwaUpdatePrompt />
|
||||||
<AppTourOverlay />
|
<App />
|
||||||
</AppTourProvider>
|
<AppTourOverlay />
|
||||||
<AppFooter />
|
</AppTourProvider>
|
||||||
</UnsavedChangesProvider>
|
<AppFooter />
|
||||||
</DialogProvider>
|
</UnsavedChangesProvider>
|
||||||
|
</DialogProvider>
|
||||||
|
</AppErrorBoundary>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import { useEffect, useState, type ReactNode } from 'react'
|
||||||
|
import {
|
||||||
|
fetchAdminMe,
|
||||||
|
fetchAdminSummary,
|
||||||
|
fetchAdminTimeSeries,
|
||||||
|
type AdminSummary,
|
||||||
|
type AdminTimeSeriesResponse,
|
||||||
|
type AdminTimeBucket
|
||||||
|
} from '../services/adminApi.js'
|
||||||
|
import { BarChart2, Bookmark, ChevronLeft, Database, Image, MapPin, Mic, Users } from 'lucide-react'
|
||||||
|
|
||||||
|
function formatNumber(value: number): string {
|
||||||
|
return value.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number | undefined): string {
|
||||||
|
if (bytes === undefined || bytes === null) return '—'
|
||||||
|
if (bytes === 0) return '0 B'
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
const num = bytes / Math.pow(k, i)
|
||||||
|
return `${num.toFixed(1)} ${sizes[i]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiCard({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
value
|
||||||
|
}: {
|
||||||
|
icon: ReactNode
|
||||||
|
label: string
|
||||||
|
value: number | string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="stats-kpi-card glass">
|
||||||
|
<div className="stats-kpi-icon">{icon}</div>
|
||||||
|
<div className="stats-kpi-body">
|
||||||
|
<span className="stats-kpi-label">{label}</span>
|
||||||
|
<span className="stats-kpi-value">{typeof value === 'number' ? formatNumber(value) : value}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimeSeriesChart({
|
||||||
|
title,
|
||||||
|
seriesKey,
|
||||||
|
data
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
seriesKey: string
|
||||||
|
data: AdminTimeSeriesResponse | null
|
||||||
|
}) {
|
||||||
|
if (!data) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const metric = data.series.find((s) => s.metric === seriesKey)
|
||||||
|
if (!metric || metric.points.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="form-card glass">
|
||||||
|
<div className="form-header">
|
||||||
|
<BarChart2 className="form-icon" />
|
||||||
|
<h2>{title}</h2>
|
||||||
|
</div>
|
||||||
|
<p className="dashboard-status-msg">Keine Daten im gewählten Zeitraum.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const max = metric.points.reduce((acc, p) => (p.count > acc ? p.count : acc), 0) || 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="form-card glass">
|
||||||
|
<div className="form-header">
|
||||||
|
<BarChart2 className="form-icon" />
|
||||||
|
<h2>{title}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="stats-bar-chart" role="img" aria-label={title}>
|
||||||
|
{metric.points.map((point) => {
|
||||||
|
const heightPct = Math.max(2, (point.count / max) * 100)
|
||||||
|
return (
|
||||||
|
<div key={point.date} className="stats-bar-column" title={`${point.date}: ${point.count}`}>
|
||||||
|
<span className="stats-bar-value">{point.count > 0 ? String(point.count) : ''}</span>
|
||||||
|
<div className="stats-bar-track">
|
||||||
|
<div className="stats-bar stats-bar--distance" style={{ height: `${heightPct}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="stats-bar-label">{point.date.slice(5)}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminDashboardProps {
|
||||||
|
onBack: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminDashboard({ onBack }: AdminDashboardProps) {
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [summary, setSummary] = useState<AdminSummary | null>(null)
|
||||||
|
const [timeSeries, setTimeSeries] = useState<AdminTimeSeriesResponse | null>(null)
|
||||||
|
const [bucket, setBucket] = useState<AdminTimeBucket>('day')
|
||||||
|
const [windowDays, setWindowDays] = useState(90)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
await fetchAdminMe()
|
||||||
|
const [summaryRes, tsRes] = await Promise.all([
|
||||||
|
fetchAdminSummary(),
|
||||||
|
fetchAdminTimeSeries({ bucket, windowDays })
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!cancelled) {
|
||||||
|
setSummary(summaryRes)
|
||||||
|
setTimeSeries(tsRes)
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (!cancelled) {
|
||||||
|
const message =
|
||||||
|
err instanceof Error && err.message ? err.message : 'Fehler beim Laden des Admin-Dashboards'
|
||||||
|
setError(message)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void load()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [bucket, windowDays])
|
||||||
|
|
||||||
|
if (loading && !summary) {
|
||||||
|
return (
|
||||||
|
<div className="admin-page">
|
||||||
|
<p className="dashboard-status-msg">Admin-Dashboard wird geladen…</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="admin-page">
|
||||||
|
<header className="admin-header">
|
||||||
|
<button type="button" className="btn-back" onClick={onBack}>
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
Zur App
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<p className="dashboard-status-msg">{error}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!summary) {
|
||||||
|
return (
|
||||||
|
<div className="admin-page">
|
||||||
|
<p className="dashboard-status-msg">Keine Admin-Daten verfügbar.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-page">
|
||||||
|
<header className="admin-header">
|
||||||
|
<div className="admin-header-left">
|
||||||
|
<button type="button" className="btn-back" onClick={onBack}>
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
Zur App
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h1 className="admin-title">Admin-Dashboard</h1>
|
||||||
|
<p className="admin-subtitle">
|
||||||
|
Übersicht über Nutzung und Wachstum von Kapteins Daagbok.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="admin-main">
|
||||||
|
<section className="stats-kpi-grid admin-kpi-grid">
|
||||||
|
<KpiCard icon={<Users size={20} />} label="Registrierte Benutzer" value={summary.totalUsers} />
|
||||||
|
<KpiCard icon={<Bookmark size={20} />} label="Logbücher" value={summary.totalLogbooks} />
|
||||||
|
<KpiCard icon={<Image size={20} />} label="Fotos" value={summary.totalPhotos} />
|
||||||
|
<KpiCard icon={<Mic size={20} />} label="Sprachmemos" value={summary.totalVoiceMemos} />
|
||||||
|
<KpiCard icon={<MapPin size={20} />} label="GPS-Tracks" value={summary.totalGpsTracks} />
|
||||||
|
<KpiCard
|
||||||
|
icon={<BarChart2 size={20} />}
|
||||||
|
label="Einträge mit AI-Zusammenfassung"
|
||||||
|
value={summary.aiSummaryEntries}
|
||||||
|
/>
|
||||||
|
<KpiCard icon={<Database size={20} />} label="Datenbankgröße" value={formatBytes(summary.dbSize)} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="admin-controls">
|
||||||
|
<div className="admin-control-group">
|
||||||
|
<span className="admin-control-label">Zeitraum</span>
|
||||||
|
<div className="admin-control-buttons">
|
||||||
|
{[30, 90, 365].map((days) => (
|
||||||
|
<button
|
||||||
|
key={days}
|
||||||
|
type="button"
|
||||||
|
className={days === windowDays ? 'btn primary' : 'btn secondary'}
|
||||||
|
onClick={() => setWindowDays(days)}
|
||||||
|
>
|
||||||
|
{days} Tage
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="admin-control-group">
|
||||||
|
<span className="admin-control-label">Aggregation</span>
|
||||||
|
<div className="admin-control-buttons">
|
||||||
|
{(['day', 'week', 'month'] as AdminTimeBucket[]).map((b) => (
|
||||||
|
<button
|
||||||
|
key={b}
|
||||||
|
type="button"
|
||||||
|
className={b === bucket ? 'btn primary' : 'btn secondary'}
|
||||||
|
onClick={() => setBucket(b)}
|
||||||
|
>
|
||||||
|
{b === 'day' ? 'Tag' : b === 'week' ? 'Woche' : 'Monat'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="admin-charts-grid">
|
||||||
|
<TimeSeriesChart title="Neue Benutzer" seriesKey="users_created" data={timeSeries} />
|
||||||
|
<TimeSeriesChart title="Neue Logbücher" seriesKey="logbooks_created" data={timeSeries} />
|
||||||
|
<TimeSeriesChart title="Foto-Aktivität" seriesKey="photos_updated" data={timeSeries} />
|
||||||
|
<TimeSeriesChart title="Datenbankgröße (MB)" seriesKey="database_size" data={timeSeries} />
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -46,6 +46,7 @@ export default function AccountDangerZone({ className = '' }: AccountDangerZoneP
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="account-danger-zone__desc">{t('settings.danger_zone_desc')}</p>
|
<p className="account-danger-zone__desc">{t('settings.danger_zone_desc')}</p>
|
||||||
|
<p className="account-danger-zone__hint">{t('settings.delete_backup_hint')}</p>
|
||||||
|
|
||||||
<div className="form-actions account-danger-zone__actions">
|
<div className="form-actions account-danger-zone__actions">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { LayoutDashboard } from 'lucide-react'
|
||||||
|
|
||||||
|
interface AdminHeaderButtonProps {
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminHeaderButton({ onClick }: AdminHeaderButtonProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-icon skipper-badge"
|
||||||
|
onClick={onClick}
|
||||||
|
title={t('nav.admin')}
|
||||||
|
aria-label={t('nav.admin')}
|
||||||
|
>
|
||||||
|
<LayoutDashboard size={18} aria-hidden="true" />
|
||||||
|
<span className="skipper-badge__name">{t('nav.admin')}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Component, type ErrorInfo, type ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
error: Error | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class AppErrorBoundary extends Component<Props, State> {
|
||||||
|
state: State = { error: null }
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { error }
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||||
|
console.error('Unhandled app error:', error, info.componentStack)
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!this.state.error) {
|
||||||
|
return this.props.children
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="auth-screen">
|
||||||
|
<div className="auth-card glass" role="alert">
|
||||||
|
<h2 style={{ marginTop: 0 }}>Kapteins Daagbok</h2>
|
||||||
|
<p style={{ color: 'var(--app-text-muted)', lineHeight: 1.5 }}>
|
||||||
|
Die App ist nach dem Neustart in einen fehlerhaften Zustand geraten. Bitte neu laden
|
||||||
|
oder die App vollständig beenden und erneut öffnen.
|
||||||
|
</p>
|
||||||
|
<button type="button" className="btn primary" style={{ width: '100%', marginTop: 16 }} onClick={() => window.location.reload()}>
|
||||||
|
Neu laden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
|
import { Coffee, Mail, Compass } from 'lucide-react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
|
|
||||||
const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'
|
const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'
|
||||||
|
const KOFI_URL = 'https://ko-fi.com/kapteinsdaagbok'
|
||||||
|
|
||||||
export default function AppFooter() {
|
export default function AppFooter() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="app-version-footer">
|
<footer className="app-version-footer">
|
||||||
<span className="app-version-footer__version">v{APP_VERSION}</span>
|
<span className="app-version-footer__version">v{APP_VERSION}</span>
|
||||||
@@ -10,14 +15,47 @@ export default function AppFooter() {
|
|||||||
·
|
·
|
||||||
</span>
|
</span>
|
||||||
<span className="app-version-footer__copyright">
|
<span className="app-version-footer__copyright">
|
||||||
© 2026 KnorrLabs/
|
© 2026
|
||||||
<a
|
|
||||||
href="mailto:elpatron+kd@mailbox.org"
|
|
||||||
onClick={() => trackPlausibleEvent(PlausibleEvents.FOOTER_LINK_CLICKED)}
|
|
||||||
>
|
|
||||||
Markus F.J. Busche
|
|
||||||
</a>
|
|
||||||
</span>
|
</span>
|
||||||
|
<span className="app-version-footer__sep" aria-hidden="true">
|
||||||
|
·
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
className="knorrlabs-footer-badge"
|
||||||
|
href="https://dashy.elpatron.me/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={() => trackPlausibleEvent(PlausibleEvents.FOOTER_LINK_CLICKED)}
|
||||||
|
>
|
||||||
|
<Compass size={14} aria-hidden="true" />
|
||||||
|
<span>KnorrLabs</span>
|
||||||
|
</a>
|
||||||
|
<span className="app-version-footer__sep" aria-hidden="true">
|
||||||
|
·
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
className="mail-footer-badge"
|
||||||
|
href="mailto:moin@kapteins-daagbok.eu"
|
||||||
|
onClick={() => trackPlausibleEvent(PlausibleEvents.FOOTER_LINK_CLICKED)}
|
||||||
|
>
|
||||||
|
<Mail size={14} aria-hidden="true" />
|
||||||
|
<span>moin@kapteins-daagbok.eu</span>
|
||||||
|
</a>
|
||||||
|
<span className="app-version-footer__sep" aria-hidden="true">
|
||||||
|
·
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
className="kofi-footer-badge"
|
||||||
|
href={KOFI_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title={t('footer.kofi_title')}
|
||||||
|
aria-label={t('footer.kofi_title')}
|
||||||
|
onClick={() => trackPlausibleEvent(PlausibleEvents.KOFI_LINK_CLICKED)}
|
||||||
|
>
|
||||||
|
<Coffee size={14} aria-hidden="true" />
|
||||||
|
<span>{t('footer.kofi_label')}</span>
|
||||||
|
</a>
|
||||||
</footer>
|
</footer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,27 +1,43 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
import LanguageDropdown from './LanguageDropdown.tsx'
|
||||||
registerUser,
|
import {
|
||||||
loginUser,
|
registerUser,
|
||||||
completeLoginWithRecovery,
|
loginUser,
|
||||||
setLocalPin,
|
completeLoginWithRecovery,
|
||||||
hasLocalPin,
|
setLocalPin,
|
||||||
decryptWithLocalPin,
|
hasLocalPin,
|
||||||
|
decryptWithLocalPin,
|
||||||
getActiveMasterKey,
|
getActiveMasterKey,
|
||||||
getKnownUsernames,
|
getKnownUsernames,
|
||||||
forgetUsername
|
forgetUsername,
|
||||||
|
hasUnlockedLocalSession,
|
||||||
|
logoutUser,
|
||||||
|
resolveRestoreUsername
|
||||||
} from '../services/auth.js'
|
} from '../services/auth.js'
|
||||||
import { KeyRound, ShieldAlert, Languages, HelpCircle, UserRound, X } from 'lucide-react'
|
import { KeyRound, ShieldAlert, 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'
|
||||||
|
import {
|
||||||
|
isPasskeyCompatibleLocation,
|
||||||
|
localizeWebAuthnError,
|
||||||
|
toPasskeyCompatibleUrl
|
||||||
|
} from '../utils/passkeyHost.ts'
|
||||||
|
|
||||||
interface AuthOnboardingProps {
|
interface AuthOnboardingProps {
|
||||||
onAuthenticated: () => void
|
onAuthenticated: () => void
|
||||||
onOpenDemo?: () => void
|
onOpenDemo?: () => void
|
||||||
|
/** Server session cookie is valid but the in-memory master key was lost (e.g. after reload). */
|
||||||
|
restoreSession?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnboardingProps) {
|
export default function AuthOnboarding({
|
||||||
const { t, i18n } = useTranslation()
|
onAuthenticated,
|
||||||
|
onOpenDemo,
|
||||||
|
restoreSession = false
|
||||||
|
}: AuthOnboardingProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -50,6 +66,20 @@ 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 [showStandardLogin, setShowStandardLogin] = useState(false)
|
||||||
|
const autoUnlockAttempted = useRef(false)
|
||||||
|
|
||||||
|
const isRestoreFlow = restoreSession && !showStandardLogin
|
||||||
|
const passkeyHostOk = isPasskeyCompatibleLocation()
|
||||||
|
const passkeyCompatibleUrl = passkeyHostOk ? null : toPasskeyCompatibleUrl(window.location.href)
|
||||||
|
|
||||||
|
const formatAuthError = (message: string) =>
|
||||||
|
localizeWebAuthnError(message, {
|
||||||
|
invalidHost: t('auth.error_invalid_host'),
|
||||||
|
cancelled: t('auth.error_passkey_cancelled'),
|
||||||
|
invalidRpId: t('auth.error_invalid_rp_id')
|
||||||
|
})
|
||||||
|
|
||||||
const finishAuth = () => {
|
const finishAuth = () => {
|
||||||
if (isNewRegistration) {
|
if (isNewRegistration) {
|
||||||
@@ -78,7 +108,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
|||||||
setRecoveryPhrase(result.recoveryPhrase)
|
setRecoveryPhrase(result.recoveryPhrase)
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Registration failed')
|
setError(formatAuthError(err.message || 'Registration failed'))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -118,12 +148,29 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Login failed')
|
setError(formatAuthError(err.message || 'Login failed'))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isRestoreFlow || autoUnlockAttempted.current) return
|
||||||
|
|
||||||
|
const user = resolveRestoreUsername()
|
||||||
|
if (user && hasLocalPin(user)) {
|
||||||
|
autoUnlockAttempted.current = true
|
||||||
|
setUsername(user)
|
||||||
|
setShowPinLogin(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user && passkeyHostOk) {
|
||||||
|
autoUnlockAttempted.current = true
|
||||||
|
void handleLogin(user)
|
||||||
|
}
|
||||||
|
}, [isRestoreFlow, passkeyHostOk])
|
||||||
|
|
||||||
const handleRecoverySubmit = async (e: React.FormEvent) => {
|
const handleRecoverySubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!recoveryInput.trim() || !encryptedPayloads) return
|
if (!recoveryInput.trim() || !encryptedPayloads) return
|
||||||
@@ -182,19 +229,33 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
|||||||
|
|
||||||
const handlePinLoginSubmit = async (e: React.FormEvent) => {
|
const handlePinLoginSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!pinLoginInput.trim()) return
|
if (!pinLoginInput.trim() || loading) return
|
||||||
|
|
||||||
|
const resolvedUser =
|
||||||
|
username.trim() ||
|
||||||
|
encryptedPayloads?.username ||
|
||||||
|
localStorage.getItem('active_username') ||
|
||||||
|
''
|
||||||
|
if (!resolvedUser) {
|
||||||
|
setError(t('auth.error_session_incomplete'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const resolvedUser = username.trim() || encryptedPayloads?.username
|
|
||||||
const key = await decryptWithLocalPin(pinLoginInput.trim(), resolvedUser)
|
const key = await decryptWithLocalPin(pinLoginInput.trim(), resolvedUser)
|
||||||
if (key) {
|
if (!key) {
|
||||||
onAuthenticated()
|
|
||||||
} else {
|
|
||||||
setError(t('auth.error_incorrect_pin'))
|
setError(t('auth.error_incorrect_pin'))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
if (!hasUnlockedLocalSession()) {
|
||||||
|
setError(t('auth.error_session_incomplete'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setShowPinLogin(false)
|
||||||
|
onAuthenticated()
|
||||||
|
} catch {
|
||||||
setError(t('auth.error_incorrect_pin'))
|
setError(t('auth.error_incorrect_pin'))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
@@ -206,10 +267,6 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
|||||||
setKnownUsers(getKnownUsernames())
|
setKnownUsers(getKnownUsernames())
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleLanguage = () => {
|
|
||||||
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
|
|
||||||
i18n.changeLanguage(nextLang)
|
|
||||||
}
|
|
||||||
|
|
||||||
const copyToClipboard = () => {
|
const copyToClipboard = () => {
|
||||||
if (recoveryPhrase) {
|
if (recoveryPhrase) {
|
||||||
@@ -314,10 +371,10 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
|||||||
<div className="auth-card glass">
|
<div className="auth-card glass">
|
||||||
<div className="auth-header">
|
<div className="auth-header">
|
||||||
<KeyRound className="auth-icon accent" size={48} />
|
<KeyRound className="auth-icon accent" size={48} />
|
||||||
<h2>{t('auth.enter_pin_title')}</h2>
|
<h2>{isRestoreFlow ? t('auth.restore_title') : t('auth.enter_pin_title')}</h2>
|
||||||
</div>
|
</div>
|
||||||
<p className="recovery-warning">
|
<p className="recovery-warning">
|
||||||
{t('auth.enter_pin_warning')}
|
{isRestoreFlow ? t('auth.restore_pin_warning') : t('auth.enter_pin_warning')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form onSubmit={handlePinLoginSubmit} className="auth-form">
|
<form onSubmit={handlePinLoginSubmit} className="auth-form">
|
||||||
@@ -359,6 +416,30 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
|||||||
>
|
>
|
||||||
{t('auth.use_recovery_instead')}
|
{t('auth.use_recovery_instead')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={() => {
|
||||||
|
if (isRestoreFlow) {
|
||||||
|
setShowPinLogin(false)
|
||||||
|
setPinLoginInput('')
|
||||||
|
setError(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void (async () => {
|
||||||
|
setShowPinLogin(false)
|
||||||
|
setPinLoginInput('')
|
||||||
|
setEncryptedPayloads(null)
|
||||||
|
setError(null)
|
||||||
|
await logoutUser()
|
||||||
|
})()
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
{t('auth.back')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -377,16 +458,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>}
|
||||||
|
|
||||||
@@ -408,8 +510,104 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render: Session restore (active server cookie, master key lost after reload)
|
||||||
|
if (isRestoreFlow) {
|
||||||
|
const restoreUser = resolveRestoreUsername()
|
||||||
|
const restoreKnownUsers = getKnownUsernames()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="auth-card glass">
|
||||||
|
<div className="auth-header">
|
||||||
|
<KeyRound className="auth-icon accent" size={48} />
|
||||||
|
<h2>{t('auth.restore_title')}</h2>
|
||||||
|
</div>
|
||||||
|
<p className="recovery-warning">{t('auth.restore_subtitle')}</p>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<p className="dashboard-status-msg" style={{ marginTop: '12px' }}>
|
||||||
|
{t('auth.restore_unlocking')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <div className="auth-error">{error}</div>}
|
||||||
|
|
||||||
|
{!loading && (
|
||||||
|
<div className="auth-actions" style={{ flexDirection: 'column', gap: '10px', marginTop: '16px' }}>
|
||||||
|
{restoreUser && passkeyHostOk && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn primary"
|
||||||
|
onClick={() => handleLogin(restoreUser)}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
{t('auth.restore_with_passkey', { name: restoreUser })}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{restoreUser && hasLocalPin(restoreUser) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setUsername(restoreUser)
|
||||||
|
setShowPinLogin(true)
|
||||||
|
}}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
{t('auth.restore_with_pin')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{restoreKnownUsers.length > 1 && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', width: '100%' }}>
|
||||||
|
<span style={{ fontSize: '12px', color: '#64748b', textTransform: 'uppercase' }}>
|
||||||
|
{t('auth.quick_login')}
|
||||||
|
</span>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', width: '100%' }}>
|
||||||
|
{restoreKnownUsers.map((name) => (
|
||||||
|
<button
|
||||||
|
key={name}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (hasLocalPin(name)) {
|
||||||
|
setUsername(name)
|
||||||
|
setShowPinLogin(true)
|
||||||
|
} else {
|
||||||
|
void handleLogin(name)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
className="btn secondary"
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: '6px' }}
|
||||||
|
>
|
||||||
|
<UserRound size={16} />
|
||||||
|
{name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setShowStandardLogin(true)
|
||||||
|
setError(null)
|
||||||
|
}}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
{t('auth.restore_other_account')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 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" />
|
||||||
@@ -421,12 +619,21 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="auth-form" style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
<div className="auth-form" style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||||
|
{!passkeyHostOk && passkeyCompatibleUrl && (
|
||||||
|
<div className="auth-error" role="alert">
|
||||||
|
<p style={{ margin: '0 0 8px' }}>{t('auth.error_invalid_host')}</p>
|
||||||
|
<a href={passkeyCompatibleUrl} className="btn secondary" style={{ display: 'inline-block', textDecoration: 'none' }}>
|
||||||
|
{t('auth.use_localhost_link')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Prominent Login button */}
|
{/* Prominent Login button */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn primary"
|
className="btn primary"
|
||||||
onClick={() => handleLogin()}
|
onClick={() => handleLogin()}
|
||||||
disabled={loading}
|
disabled={loading || !passkeyHostOk}
|
||||||
style={{ width: '100%', padding: '16px' }}
|
style={{ width: '100%', padding: '16px' }}
|
||||||
>
|
>
|
||||||
{loading
|
{loading
|
||||||
@@ -559,7 +766,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn secondary"
|
className="btn secondary"
|
||||||
disabled={loading || !username.trim()}
|
disabled={loading || !username.trim() || !passkeyHostOk}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
>
|
>
|
||||||
{t('auth.register')}
|
{t('auth.register')}
|
||||||
@@ -570,15 +777,20 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="auth-footer">
|
<div className="auth-footer">
|
||||||
<button className="btn-icon-text" onClick={toggleLanguage}>
|
<LanguageDropdown variant="text" align="left" />
|
||||||
<Languages size={18} />
|
<button
|
||||||
{i18n.language.startsWith('de') ? 'English' : 'Deutsch'}
|
type="button"
|
||||||
</button>
|
className="btn-icon-text link-sec"
|
||||||
<a href="#help" 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)} />
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import { useCallback, useId, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import {
|
||||||
|
type CourseOutputMode,
|
||||||
|
type CourseStep,
|
||||||
|
dialDegreesToStorageValue,
|
||||||
|
formatCourseAngle,
|
||||||
|
formatCourseDisplay,
|
||||||
|
isCardinalDirection,
|
||||||
|
loadCourseDialStep,
|
||||||
|
parseCourseAngle,
|
||||||
|
pointerAngleToDegrees,
|
||||||
|
resolveCourseOutputMode,
|
||||||
|
saveCourseDialStep,
|
||||||
|
snapDegrees,
|
||||||
|
valueToDialDegrees
|
||||||
|
} from '../utils/courseAngle.js'
|
||||||
|
|
||||||
|
interface CourseDialInputProps {
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
disabled?: boolean
|
||||||
|
step?: CourseStep
|
||||||
|
allowCardinal?: boolean
|
||||||
|
displayMode?: 'degrees' | 'cardinal' | 'auto'
|
||||||
|
size?: 'md' | 'sm'
|
||||||
|
'aria-label': string
|
||||||
|
id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const TICK_DEGREES = [0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330]
|
||||||
|
|
||||||
|
function polarPoint(degrees: number, radius: number): { x: number; y: number } {
|
||||||
|
const rad = (degrees * Math.PI) / 180
|
||||||
|
return {
|
||||||
|
x: 100 + Math.sin(rad) * radius,
|
||||||
|
y: 100 - Math.cos(rad) * radius
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CourseDialInput({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled = false,
|
||||||
|
step: stepProp,
|
||||||
|
allowCardinal = false,
|
||||||
|
displayMode = 'degrees',
|
||||||
|
size = 'md',
|
||||||
|
'aria-label': ariaLabel,
|
||||||
|
id: idProp
|
||||||
|
}: CourseDialInputProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const generatedId = useId()
|
||||||
|
const inputId = idProp ?? `${generatedId}-input`
|
||||||
|
const svgRef = useRef<SVGSVGElement>(null)
|
||||||
|
const [step, setStep] = useState<CourseStep>(() => stepProp ?? loadCourseDialStep())
|
||||||
|
const [inputDraft, setInputDraft] = useState<string | null>(null)
|
||||||
|
const [inputError, setInputError] = useState<string | null>(null)
|
||||||
|
const [outputModeOverride, setOutputModeOverride] = useState<CourseOutputMode | null>(null)
|
||||||
|
|
||||||
|
const effectiveStep = stepProp ?? step
|
||||||
|
const outputMode =
|
||||||
|
outputModeOverride ??
|
||||||
|
resolveCourseOutputMode(value, displayMode, allowCardinal)
|
||||||
|
|
||||||
|
const dialDegrees = useMemo(
|
||||||
|
() => snapDegrees(valueToDialDegrees(value, allowCardinal), effectiveStep),
|
||||||
|
[value, allowCardinal, effectiveStep]
|
||||||
|
)
|
||||||
|
|
||||||
|
const centerLabel = useMemo(
|
||||||
|
() => formatCourseDisplay(value, allowCardinal),
|
||||||
|
[value, allowCardinal]
|
||||||
|
)
|
||||||
|
|
||||||
|
const tickLabel = useCallback(
|
||||||
|
(degrees: number) => {
|
||||||
|
if (degrees === 0) return t('logs.compass_n')
|
||||||
|
if (degrees === 90) return t('logs.compass_e')
|
||||||
|
if (degrees === 180) return t('logs.compass_s')
|
||||||
|
if (degrees === 270) return t('logs.compass_w')
|
||||||
|
return String(degrees).padStart(3, '0')
|
||||||
|
},
|
||||||
|
[t]
|
||||||
|
)
|
||||||
|
|
||||||
|
const applyDegrees = useCallback(
|
||||||
|
(degrees: number) => {
|
||||||
|
onChange(dialDegreesToStorageValue(degrees, outputMode, effectiveStep))
|
||||||
|
setInputDraft(null)
|
||||||
|
setInputError(null)
|
||||||
|
},
|
||||||
|
[onChange, outputMode, effectiveStep]
|
||||||
|
)
|
||||||
|
|
||||||
|
const updateFromPointer = useCallback(
|
||||||
|
(clientX: number, clientY: number) => {
|
||||||
|
const svg = svgRef.current
|
||||||
|
if (!svg || disabled) return
|
||||||
|
const rect = svg.getBoundingClientRect()
|
||||||
|
const cx = rect.left + rect.width / 2
|
||||||
|
const cy = rect.top + rect.height / 2
|
||||||
|
const raw = pointerAngleToDegrees(clientX, clientY, cx, cy)
|
||||||
|
applyDegrees(raw)
|
||||||
|
},
|
||||||
|
[applyDegrees, disabled]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handlePointerDown = (e: React.PointerEvent<SVGSVGElement>) => {
|
||||||
|
if (disabled) return
|
||||||
|
e.preventDefault()
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId)
|
||||||
|
updateFromPointer(e.clientX, e.clientY)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePointerMove = (e: React.PointerEvent<SVGSVGElement>) => {
|
||||||
|
if (disabled || !e.currentTarget.hasPointerCapture(e.pointerId)) return
|
||||||
|
updateFromPointer(e.clientX, e.clientY)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePointerUp = (e: React.PointerEvent<SVGSVGElement>) => {
|
||||||
|
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
||||||
|
e.currentTarget.releasePointerCapture(e.pointerId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setInputDraft(e.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const commitInput = () => {
|
||||||
|
const draft = (inputDraft ?? value).trim()
|
||||||
|
setInputDraft(null)
|
||||||
|
if (!draft) {
|
||||||
|
onChange('')
|
||||||
|
setInputError(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (allowCardinal && outputMode === 'cardinal' && isCardinalDirection(draft)) {
|
||||||
|
onChange(draft.toUpperCase())
|
||||||
|
setInputError(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const parsed = parseCourseAngle(draft)
|
||||||
|
if (parsed === null) {
|
||||||
|
setInputError(t('logs.course_invalid'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onChange(formatCourseAngle(snapDegrees(parsed, effectiveStep)))
|
||||||
|
setInputError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
commitInput()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault()
|
||||||
|
const base = parseCourseAngle(value) ?? dialDegrees
|
||||||
|
const delta = e.key === 'ArrowUp' ? effectiveStep : -effectiveStep
|
||||||
|
applyDegrees(base + delta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStepChange = (next: CourseStep) => {
|
||||||
|
if (stepProp !== undefined) return
|
||||||
|
setStep(next)
|
||||||
|
saveCourseDialStep(next)
|
||||||
|
const parsed = parseCourseAngle(value)
|
||||||
|
if (parsed !== null) {
|
||||||
|
onChange(formatCourseAngle(snapDegrees(parsed, next)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleOutputMode = () => {
|
||||||
|
const next: CourseOutputMode = outputMode === 'cardinal' ? 'degrees' : 'cardinal'
|
||||||
|
setOutputModeOverride(next)
|
||||||
|
const deg = valueToDialDegrees(value, allowCardinal)
|
||||||
|
onChange(dialDegreesToStorageValue(deg, next, effectiveStep))
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputValue = inputDraft ?? value
|
||||||
|
const sliderNow = dialDegrees
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`course-dial course-dial--${size}${disabled ? ' course-dial--disabled' : ''}`}
|
||||||
|
>
|
||||||
|
{!stepProp && (
|
||||||
|
<div className="course-dial__step-toolbar" role="group" aria-label={t('logs.course_dial_step_label')}>
|
||||||
|
{([1, 5, 10] as const).map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
type="button"
|
||||||
|
className={`course-dial__step-btn${effectiveStep === s ? ' is-active' : ''}`}
|
||||||
|
onClick={() => handleStepChange(s)}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-pressed={effectiveStep === s}
|
||||||
|
>
|
||||||
|
{s === 1 ? t('logs.course_step_fine') : s === 5 ? t('logs.course_step_medium') : t('logs.course_step_coarse')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="course-dial__ring-wrap"
|
||||||
|
role="slider"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={360}
|
||||||
|
aria-valuenow={sliderNow}
|
||||||
|
aria-disabled={disabled}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
ref={svgRef}
|
||||||
|
className="course-dial__svg"
|
||||||
|
viewBox="0 0 200 200"
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerCancel={handlePointerUp}
|
||||||
|
>
|
||||||
|
<circle className="course-dial__track" cx="100" cy="100" r="88" />
|
||||||
|
{TICK_DEGREES.map((deg) => {
|
||||||
|
const inner = polarPoint(deg, 76)
|
||||||
|
const outer = polarPoint(deg, 88)
|
||||||
|
const label = polarPoint(deg, 64)
|
||||||
|
return (
|
||||||
|
<g key={deg}>
|
||||||
|
<line className="course-dial__tick" x1={inner.x} y1={inner.y} x2={outer.x} y2={outer.y} />
|
||||||
|
<text className="course-dial__label" x={label.x} y={label.y} textAnchor="middle" dominantBaseline="middle">
|
||||||
|
{tickLabel(deg)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<g className="course-dial__needle" transform={`rotate(${dialDegrees} 100 100)`}>
|
||||||
|
<line x1="100" y1="100" x2="100" y2="28" />
|
||||||
|
<circle cx="100" cy="100" r="6" />
|
||||||
|
</g>
|
||||||
|
<text className="course-dial__center" x="100" y="100" textAnchor="middle" dominantBaseline="middle">
|
||||||
|
{centerLabel}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="course-dial__hint">{t('logs.course_dial_hint')}</p>
|
||||||
|
|
||||||
|
<input
|
||||||
|
id={inputId}
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
className="input-text course-dial__input"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onBlur={commitInput}
|
||||||
|
onKeyDown={handleInputKeyDown}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={
|
||||||
|
outputMode === 'cardinal'
|
||||||
|
? t('logs.course_placeholder_cardinal')
|
||||||
|
: t('logs.course_placeholder_degrees')
|
||||||
|
}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-invalid={inputError ? true : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{inputError && <p className="course-dial__error">{inputError}</p>}
|
||||||
|
|
||||||
|
{allowCardinal && displayMode === 'auto' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="course-dial__mode-toggle"
|
||||||
|
onClick={toggleOutputMode}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{outputMode === 'cardinal' ? t('logs.wind_mode_degrees') : t('logs.wind_mode_cardinal')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
interface PersonSnapshot {
|
||||||
|
name: string
|
||||||
|
photo?: string | null
|
||||||
|
role?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreatorAvatarProps {
|
||||||
|
creatorId?: string
|
||||||
|
crewSnapshotsById?: Record<string, PersonSnapshot>
|
||||||
|
fallbackName?: string
|
||||||
|
size?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const colors = [
|
||||||
|
'#2563eb', // blue
|
||||||
|
'#059669', // emerald
|
||||||
|
'#d97706', // amber
|
||||||
|
'#dc2626', // red
|
||||||
|
'#7c3aed', // violet
|
||||||
|
'#db2777', // pink
|
||||||
|
'#0891b2', // cyan
|
||||||
|
'#4f46e5', // indigo
|
||||||
|
'#0f766e', // teal
|
||||||
|
'#9333ea', // purple
|
||||||
|
]
|
||||||
|
|
||||||
|
function getAvatarColor(name: string): string {
|
||||||
|
let hash = 0
|
||||||
|
for (let i = 0; i < name.length; i++) {
|
||||||
|
hash = name.charCodeAt(i) + ((hash << 5) - hash)
|
||||||
|
}
|
||||||
|
const index = Math.abs(hash) % colors.length
|
||||||
|
return colors[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CreatorAvatar({
|
||||||
|
creatorId,
|
||||||
|
crewSnapshotsById,
|
||||||
|
fallbackName,
|
||||||
|
size = 28
|
||||||
|
}: CreatorAvatarProps) {
|
||||||
|
let name = ''
|
||||||
|
let photo: string | null = null
|
||||||
|
let role = ''
|
||||||
|
|
||||||
|
if (creatorId && crewSnapshotsById) {
|
||||||
|
let snap: PersonSnapshot | undefined = crewSnapshotsById[creatorId]
|
||||||
|
|
||||||
|
// Fallback: If not found directly by key, search by role or name or active user
|
||||||
|
if (!snap) {
|
||||||
|
if (creatorId === 'skipper') {
|
||||||
|
snap = Object.values(crewSnapshotsById).find((s) => s.role === 'skipper')
|
||||||
|
} else {
|
||||||
|
// Try to match name case-insensitively
|
||||||
|
snap = Object.values(crewSnapshotsById).find(
|
||||||
|
(s) => (s.name || '').trim().toLowerCase() === creatorId.trim().toLowerCase()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Try to match active username/userid to the skipper snapshot
|
||||||
|
if (!snap) {
|
||||||
|
const activeUsername = localStorage.getItem('active_username')
|
||||||
|
const activeUserId = localStorage.getItem('active_userid')
|
||||||
|
if (
|
||||||
|
(activeUsername && creatorId.toLowerCase() === activeUsername.toLowerCase()) ||
|
||||||
|
(activeUserId && creatorId === activeUserId)
|
||||||
|
) {
|
||||||
|
snap = Object.values(crewSnapshotsById).find((s) => s.role === 'skipper')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snap) {
|
||||||
|
name = snap.name || ''
|
||||||
|
photo = snap.photo || null
|
||||||
|
role = snap.role || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to active username if owner or no crew pool matches
|
||||||
|
if (!name) {
|
||||||
|
if (creatorId === 'skipper') {
|
||||||
|
name = fallbackName || localStorage.getItem('active_username') || 'Skipper'
|
||||||
|
role = 'skipper'
|
||||||
|
} else if (fallbackName) {
|
||||||
|
name = fallbackName
|
||||||
|
} else if (creatorId) {
|
||||||
|
// If creatorId is a username itself (fallback from LiveLogView)
|
||||||
|
name = creatorId
|
||||||
|
} else {
|
||||||
|
name = '?'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const initial = name ? name.trim().split(/\s+/)[0]?.charAt(0).toUpperCase() || '?' : '?'
|
||||||
|
const bgColor = name === '?' ? '#64748b' : getAvatarColor(name)
|
||||||
|
|
||||||
|
const style: React.CSSProperties = {
|
||||||
|
width: `${size}px`,
|
||||||
|
height: `${size}px`,
|
||||||
|
borderRadius: '50%',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: `${Math.round(size * 0.45)}px`,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#ffffff',
|
||||||
|
backgroundColor: bgColor,
|
||||||
|
flexShrink: 0,
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.15)',
|
||||||
|
boxSizing: 'border-box'
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleText = role ? (role === 'skipper' ? 'Skipper' : 'Crew') : ''
|
||||||
|
const tooltip = name + (roleText ? ` (${roleText})` : '')
|
||||||
|
|
||||||
|
if (photo) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={photo}
|
||||||
|
alt={name}
|
||||||
|
title={tooltip}
|
||||||
|
style={{
|
||||||
|
...style,
|
||||||
|
objectFit: 'cover',
|
||||||
|
backgroundColor: 'transparent'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={style} title={tooltip} className="creator-avatar-fallback">
|
||||||
|
{initial}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,6 +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 { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
|
import { MAX_POOL_CREW_MEMBERS } from '../types/person.js'
|
||||||
import { useDialog } from './ModalDialog.tsx'
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
import { Users, User, Plus, Trash2, Edit2, Save, X, Check, Camera } from 'lucide-react'
|
import { Users, User, Plus, Trash2, Edit2, Save, X, Check, Camera } from 'lucide-react'
|
||||||
|
|
||||||
@@ -603,7 +604,7 @@ export default function CrewForm({
|
|||||||
<Users size={24} className="form-icon" />
|
<Users size={24} className="form-icon" />
|
||||||
<h2>{t('crew.crew_section')}</h2>
|
<h2>{t('crew.crew_section')}</h2>
|
||||||
</div>
|
</div>
|
||||||
{!readOnly && crewList.length < 5 && !showMemberForm && (
|
{!readOnly && crewList.length < MAX_POOL_CREW_MEMBERS && !showMemberForm && (
|
||||||
<button className="btn primary" onClick={openAddMember} style={{ width: 'auto', padding: '8px 16px' }}>
|
<button className="btn primary" onClick={openAddMember} style={{ width: 'auto', padding: '8px 16px' }}>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
{t('crew.add_crew')}
|
{t('crew.add_crew')}
|
||||||
@@ -817,7 +818,7 @@ export default function CrewForm({
|
|||||||
<button className="btn-icon" onClick={() => openEditMember(m)} title="Edit">
|
<button className="btn-icon" onClick={() => openEditMember(m)} title="Edit">
|
||||||
<Edit2 size={14} />
|
<Edit2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
<button className="btn-icon logout" onClick={() => handleDeleteMember(m.payloadId)} title="Delete">
|
<button className="btn-icon danger" onClick={() => handleDeleteMember(m.payloadId)} title="Delete">
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import VesselForm from './VesselForm.tsx'
|
import LanguageDropdown from './LanguageDropdown.tsx'
|
||||||
import CrewForm from './CrewForm.tsx'
|
import LogbookVesselPicker from './LogbookVesselPicker.tsx'
|
||||||
|
import LogbookCrewPicker from './LogbookCrewPicker.tsx'
|
||||||
|
import type { LogbookCrewSelectionData } from '../types/person.js'
|
||||||
|
import { personToSnapshot } from '../utils/personSnapshots.js'
|
||||||
import LogEntriesList from './LogEntriesList.tsx'
|
import LogEntriesList from './LogEntriesList.tsx'
|
||||||
import { Ship, Users, FileText, Lock, Globe, ChevronLeft, UserPlus } from 'lucide-react'
|
import { Ship, Users, FileText, Lock, ChevronLeft, UserPlus } from 'lucide-react'
|
||||||
import { buildPublicDemoFixture, type PublicDemoFixture } from '../services/demoLogbookData.js'
|
import { buildPublicDemoFixture, type PublicDemoFixture } from '../services/demoLogbookData.js'
|
||||||
|
import type { VesselData } from '../types/vessel.js'
|
||||||
|
import type { LogbookVesselSelectionData } from '../types/vessel.js'
|
||||||
import { useAppTour, type AppTab } from '../context/AppTourContext.tsx'
|
import { useAppTour, type AppTab } from '../context/AppTourContext.tsx'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
|
|
||||||
@@ -31,7 +36,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 })
|
||||||
|
|
||||||
@@ -45,12 +52,30 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
|||||||
}
|
}
|
||||||
}, [registerNavigation, registerDemoTourContext, startTour, fixture.firstEntryId])
|
}, [registerNavigation, registerDemoTourContext, startTour, fixture.firstEntryId])
|
||||||
|
|
||||||
const toggleLanguage = () => {
|
|
||||||
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
|
|
||||||
i18n.changeLanguage(nextLang)
|
|
||||||
}
|
|
||||||
|
|
||||||
const { title, yacht, crews, entries, gpsTracks, photos, firstEntryId } = fixture
|
const {
|
||||||
|
title,
|
||||||
|
yacht,
|
||||||
|
vesselPool,
|
||||||
|
logbookVesselSelection,
|
||||||
|
personPool,
|
||||||
|
logbookCrewSelection,
|
||||||
|
entries,
|
||||||
|
gpsTracks,
|
||||||
|
photos,
|
||||||
|
firstEntryId
|
||||||
|
} = fixture
|
||||||
|
|
||||||
|
const demoSelection: LogbookCrewSelectionData = {
|
||||||
|
activeSkipperId: logbookCrewSelection.activeSkipperId,
|
||||||
|
activeCrewIds: logbookCrewSelection.activeCrewIds,
|
||||||
|
snapshotsById: Object.fromEntries(
|
||||||
|
Object.entries(logbookCrewSelection.snapshotsById).map(([id, snap]) => [
|
||||||
|
id,
|
||||||
|
personToSnapshot(id, snap)
|
||||||
|
])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-layout">
|
<div className="app-layout">
|
||||||
@@ -83,10 +108,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
|||||||
<UserPlus size={14} style={{ marginRight: '4px' }} />
|
<UserPlus size={14} style={{ marginRight: '4px' }} />
|
||||||
{t('demo.cta_register')}
|
{t('demo.cta_register')}
|
||||||
</button>
|
</button>
|
||||||
<button className="btn secondary" onClick={toggleLanguage} style={{ width: 'auto', padding: '6px 12px', fontSize: '13px' }}>
|
<LanguageDropdown variant="secondary-button" align="right" />
|
||||||
<Globe size={14} style={{ marginRight: '4px' }} />
|
|
||||||
{i18n.language.startsWith('de') ? 'English' : 'Deutsch'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -113,7 +135,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
|||||||
<button
|
<button
|
||||||
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
|
className={`sidebar-btn ${activeTab === 'crew' ? 'active' : ''}`}
|
||||||
onClick={() => setActiveTab('crew')}
|
onClick={() => setActiveTab('crew')}
|
||||||
data-tour="nav-crew"
|
data-tour="nav-logbook-crew"
|
||||||
>
|
>
|
||||||
<Users size={18} />
|
<Users size={18} />
|
||||||
{t('nav.crew')}
|
{t('nav.crew')}
|
||||||
@@ -128,6 +150,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
|||||||
preloadedYacht={yacht}
|
preloadedYacht={yacht}
|
||||||
preloadedEntries={entries}
|
preloadedEntries={entries}
|
||||||
preloadedPhotos={photos}
|
preloadedPhotos={photos}
|
||||||
|
preloadedVoiceMemos={[]}
|
||||||
preloadedGpsTracks={gpsTracks}
|
preloadedGpsTracks={gpsTracks}
|
||||||
controlledSelectedEntryId={tourSelectedEntryId}
|
controlledSelectedEntryId={tourSelectedEntryId}
|
||||||
onSelectedEntryIdChange={setTourSelectedEntryId}
|
onSelectedEntryIdChange={setTourSelectedEntryId}
|
||||||
@@ -136,11 +159,24 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'vessel' && (
|
{activeTab === 'vessel' && (
|
||||||
<VesselForm logbookId="demo" readOnly={true} preloadedData={yacht} />
|
<LogbookVesselPicker
|
||||||
|
logbookId="demo"
|
||||||
|
readOnly={true}
|
||||||
|
preloadedPool={vesselPool.map((v) => ({
|
||||||
|
payloadId: v.payloadId,
|
||||||
|
data: v.data as VesselData
|
||||||
|
}))}
|
||||||
|
preloadedSelection={logbookVesselSelection as unknown as LogbookVesselSelectionData}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'crew' && (
|
{activeTab === 'crew' && (
|
||||||
<CrewForm logbookId="demo" readOnly={true} preloadedData={crews} />
|
<LogbookCrewPicker
|
||||||
|
logbookId="demo"
|
||||||
|
readOnly={true}
|
||||||
|
preloadedPool={personPool}
|
||||||
|
preloadedSelection={demoSelection}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +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 { Compass, Save, Check } from 'lucide-react'
|
import { Compass, Save, Check } from 'lucide-react'
|
||||||
|
import { parseAppDecimalOrZero } from '../utils/numberFormat.js'
|
||||||
|
|
||||||
interface DeviationFormProps {
|
interface DeviationFormProps {
|
||||||
logbookId: string
|
logbookId: string
|
||||||
@@ -97,8 +98,8 @@ export default function DeviationForm({ logbookId, readOnly = false, preloadedDa
|
|||||||
const sanitizedDeviations: Record<number, number> = {}
|
const sanitizedDeviations: Record<number, number> = {}
|
||||||
headings.forEach((h) => {
|
headings.forEach((h) => {
|
||||||
const val = deviations[h] || ''
|
const val = deviations[h] || ''
|
||||||
const parsed = parseFloat(val.replace('+', '').trim())
|
const parsed = parseAppDecimalOrZero(val.replace('+', '').trim())
|
||||||
sanitizedDeviations[h] = isNaN(parsed) ? 0 : parsed
|
sanitizedDeviations[h] = parsed
|
||||||
})
|
})
|
||||||
|
|
||||||
const dataToSave = {
|
const dataToSave = {
|
||||||
|
|||||||
@@ -13,7 +13,12 @@ export default function DisclaimerModal({ open, onClose }: DisclaimerModalProps)
|
|||||||
if (event.key === 'Escape') onClose()
|
if (event.key === 'Escape') onClose()
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', onKeyDown)
|
window.addEventListener('keydown', onKeyDown)
|
||||||
return () => window.removeEventListener('keydown', onKeyDown)
|
const prevOverflow = document.body.style.overflow
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
|
document.body.style.overflow = prevOverflow
|
||||||
|
}
|
||||||
}, [open, onClose])
|
}, [open, onClose])
|
||||||
|
|
||||||
if (!open) return null
|
if (!open) return null
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Users, ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
|
import type { EntryCrewFields, PersonSnapshot } from '../types/person.js'
|
||||||
|
import { loadPersonPool } from '../services/personPool.js'
|
||||||
|
import { loadLogbookCrewSelection } from '../services/logbookCrewSelection.js'
|
||||||
|
import { buildSnapshotsForSelection } from '../utils/personSnapshots.js'
|
||||||
|
import type { PersonData } from '../types/person.js'
|
||||||
|
|
||||||
|
export interface EntryCrewSectionProps {
|
||||||
|
logbookId: string
|
||||||
|
readOnly?: boolean
|
||||||
|
value: EntryCrewFields
|
||||||
|
onChange: (next: EntryCrewFields) => void
|
||||||
|
/** Demo: fixed pool */
|
||||||
|
preloadedPool?: Map<string, PersonData>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EntryCrewSection({
|
||||||
|
logbookId,
|
||||||
|
readOnly = false,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
preloadedPool
|
||||||
|
}: EntryCrewSectionProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [collapsed, setCollapsed] = useState(true)
|
||||||
|
const [pool, setPool] = useState<Map<string, PersonData>>(preloadedPool ?? new Map())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (preloadedPool) {
|
||||||
|
setPool(preloadedPool)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let cancelled = false
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const people = await loadPersonPool()
|
||||||
|
if (cancelled) return
|
||||||
|
setPool(new Map(people.map((p) => [p.payloadId, p.data])))
|
||||||
|
} catch {
|
||||||
|
/* use snapshots only */
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [preloadedPool])
|
||||||
|
|
||||||
|
const displayPool = useMemo(() => {
|
||||||
|
const merged = new Map(pool)
|
||||||
|
for (const snap of Object.values(value.crewSnapshotsById)) {
|
||||||
|
if (!merged.has(snap.id)) {
|
||||||
|
merged.set(snap.id, {
|
||||||
|
name: snap.name,
|
||||||
|
address: snap.address,
|
||||||
|
birthDate: snap.birthDate,
|
||||||
|
phone: snap.phone,
|
||||||
|
nationality: snap.nationality,
|
||||||
|
passportNumber: snap.passportNumber,
|
||||||
|
bloodType: snap.bloodType,
|
||||||
|
allergies: snap.allergies,
|
||||||
|
diseases: snap.diseases,
|
||||||
|
role: snap.role,
|
||||||
|
photo: snap.photo
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}, [pool, value.crewSnapshotsById])
|
||||||
|
|
||||||
|
const skippers = [...displayPool.entries()].filter(([, d]) => d.role === 'skipper')
|
||||||
|
const crewEntries = [...displayPool.entries()].filter(([, d]) => d.role === 'crew')
|
||||||
|
|
||||||
|
const applyChange = (skipperId: string | null, crewIds: string[]) => {
|
||||||
|
const snapshots = buildSnapshotsForSelection(skipperId, crewIds, displayPool)
|
||||||
|
onChange({
|
||||||
|
selectedSkipperId: skipperId,
|
||||||
|
selectedCrewIds: crewIds,
|
||||||
|
crewSnapshotsById: snapshots
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleCrew = (id: string) => {
|
||||||
|
if (readOnly) return
|
||||||
|
const next = value.selectedCrewIds.includes(id)
|
||||||
|
? value.selectedCrewIds.filter((x) => x !== id)
|
||||||
|
: [...value.selectedCrewIds, id]
|
||||||
|
applyChange(value.selectedSkipperId, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="form-card" data-tour="entry-crew">
|
||||||
|
<div
|
||||||
|
className="form-header accordion-header"
|
||||||
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
setCollapsed(!collapsed)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
aria-expanded={!collapsed}
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<div className="accordion-header-title">
|
||||||
|
<Users size={22} className="form-icon" />
|
||||||
|
<h3>{t('entry_crew.title')}</h3>
|
||||||
|
</div>
|
||||||
|
{collapsed ? (
|
||||||
|
<ChevronDown size={20} className="accordion-chevron" />
|
||||||
|
) : (
|
||||||
|
<ChevronUp size={20} className="accordion-chevron" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!collapsed && (
|
||||||
|
<>
|
||||||
|
<p className="help-text mb-3" style={{ marginTop: '16px' }}>{t('entry_crew.subtitle')}</p>
|
||||||
|
|
||||||
|
<div className="input-group mb-3">
|
||||||
|
<label>{t('entry_crew.day_skipper')}</label>
|
||||||
|
{skippers.length === 0 ? (
|
||||||
|
<p className="help-text">{t('entry_crew.no_skipper')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="crew-selection-list">
|
||||||
|
{skippers.map(([id, data]) => (
|
||||||
|
<label key={id} className="crew-selection-item">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`entry-skipper-${logbookId}`}
|
||||||
|
checked={value.selectedSkipperId === id}
|
||||||
|
onChange={() => !readOnly && applyChange(id, value.selectedCrewIds)}
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
<span>{data.name || t('logbook_crew.unnamed')}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="input-group">
|
||||||
|
<label>{t('entry_crew.day_crew')}</label>
|
||||||
|
{crewEntries.length === 0 ? (
|
||||||
|
<p className="help-text">{t('entry_crew.no_crew')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="crew-selection-list">
|
||||||
|
{crewEntries.map(([id, data]) => (
|
||||||
|
<label key={id} className="crew-selection-item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={value.selectedCrewIds.includes(id)}
|
||||||
|
onChange={() => toggleCrew(id)}
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
<span>{data.name || t('logbook_crew.unnamed')}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadDefaultEntryCrewForNewDay(
|
||||||
|
logbookId: string,
|
||||||
|
previousEntry: Record<string, unknown> | null
|
||||||
|
): Promise<EntryCrewFields> {
|
||||||
|
if (previousEntry) {
|
||||||
|
const selectedSkipperId =
|
||||||
|
typeof previousEntry.selectedSkipperId === 'string' ? previousEntry.selectedSkipperId : null
|
||||||
|
const selectedCrewIds = Array.isArray(previousEntry.selectedCrewIds)
|
||||||
|
? previousEntry.selectedCrewIds.filter((id): id is string => typeof id === 'string')
|
||||||
|
: []
|
||||||
|
const crewSnapshotsById =
|
||||||
|
previousEntry.crewSnapshotsById && typeof previousEntry.crewSnapshotsById === 'object'
|
||||||
|
? (previousEntry.crewSnapshotsById as Record<string, PersonSnapshot>)
|
||||||
|
: {}
|
||||||
|
return { selectedSkipperId, selectedCrewIds, crewSnapshotsById }
|
||||||
|
}
|
||||||
|
|
||||||
|
const selection = await loadLogbookCrewSelection(logbookId)
|
||||||
|
return {
|
||||||
|
selectedSkipperId: selection.activeSkipperId,
|
||||||
|
selectedCrewIds: [...selection.activeCrewIds],
|
||||||
|
crewSnapshotsById: { ...selection.snapshotsById }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Mic, Loader2 } from 'lucide-react'
|
||||||
|
import type { LogEventPayload } from '../utils/logEntryPayload.js'
|
||||||
|
import { parseLiveVoiceRemark } from '../utils/liveEventCodes.js'
|
||||||
|
import { formatEventSummary } from '../utils/formatEventSummary.js'
|
||||||
|
import VoiceMemoPlayer, { type PreloadedVoiceMemo } from './VoiceMemoPlayer.tsx'
|
||||||
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
|
import { updateVoiceMemoTranscript } from '../services/voiceAttachments.js'
|
||||||
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
|
import { getAiAuthorized } from '../services/userPreferences.js'
|
||||||
|
|
||||||
|
interface EventRemarksCellProps {
|
||||||
|
event: LogEventPayload
|
||||||
|
logbookId: string
|
||||||
|
voiceMemoLookup?: Map<string, PreloadedVoiceMemo>
|
||||||
|
readOnly?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EventRemarksCell({
|
||||||
|
event,
|
||||||
|
logbookId,
|
||||||
|
voiceMemoLookup,
|
||||||
|
readOnly = false
|
||||||
|
}: EventRemarksCellProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { showAlert } = useDialog()
|
||||||
|
const voiceId = parseLiveVoiceRemark(event.remarks.trim())
|
||||||
|
const preloaded = voiceId ? voiceMemoLookup?.get(voiceId) : undefined
|
||||||
|
|
||||||
|
const [transcribing, setTranscribing] = useState(false)
|
||||||
|
const [isOnline, setIsOnline] = useState(navigator.onLine)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleOnline = () => setIsOnline(true)
|
||||||
|
const handleOffline = () => setIsOnline(false)
|
||||||
|
window.addEventListener('online', handleOnline)
|
||||||
|
window.addEventListener('offline', handleOffline)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', handleOnline)
|
||||||
|
window.removeEventListener('offline', handleOffline)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleTranscribe = async (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
if (transcribing || !preloaded?.audio || !voiceId) return
|
||||||
|
if (!getAiAuthorized()) {
|
||||||
|
void showAlert(
|
||||||
|
t('profile.ai_unauthorized_alert_desc'),
|
||||||
|
t('profile.ai_unauthorized_alert_title')
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setTranscribing(true)
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 15000)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ai/transcribe', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ audioDataUrl: preloaded.audio }),
|
||||||
|
signal: controller.signal
|
||||||
|
})
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Server returned status ${res.status}`)
|
||||||
|
}
|
||||||
|
const data = await res.json()
|
||||||
|
const text = (data.text || '').trim()
|
||||||
|
if (!text) {
|
||||||
|
throw new Error('Transcription returned empty text')
|
||||||
|
}
|
||||||
|
await updateVoiceMemoTranscript(logbookId, voiceId, text)
|
||||||
|
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, {
|
||||||
|
status: 'success',
|
||||||
|
mode: 'manual'
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
console.error('[EventRemarksCell] Transcription failed:', err)
|
||||||
|
trackPlausibleEvent(PlausibleEvents.VOICE_MEMO_TRANSCRIBED, {
|
||||||
|
status: 'failed',
|
||||||
|
mode: 'manual'
|
||||||
|
})
|
||||||
|
void showAlert(t('logs.live_voice_transcribe_failed'), t('logs.live_voice_btn'))
|
||||||
|
} finally {
|
||||||
|
setTranscribing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let summary = formatEventSummary(event, t)
|
||||||
|
if (voiceId && preloaded?.caption) {
|
||||||
|
summary = t('logs.live_voice_entry', { caption: preloaded.caption })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`event-remarks-cell${voiceId ? ' event-remarks-cell--voice' : ''}`}>
|
||||||
|
<span>{summary}</span>
|
||||||
|
{voiceId && (
|
||||||
|
<div style={{ display: 'inline-flex', alignItems: 'center', flexWrap: 'wrap', gap: '8px', marginTop: '4px' }}>
|
||||||
|
<VoiceMemoPlayer
|
||||||
|
audioId={voiceId}
|
||||||
|
logbookId={logbookId}
|
||||||
|
preloaded={preloaded}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
{!readOnly && preloaded && preloaded.transcribed === false && isOnline && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-icon-text link-sec"
|
||||||
|
style={{
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
padding: '2px 6px',
|
||||||
|
height: 'auto',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px',
|
||||||
|
margin: 0
|
||||||
|
}}
|
||||||
|
onClick={handleTranscribe}
|
||||||
|
disabled={transcribing}
|
||||||
|
title={t('logs.live_voice_transcribe_action')}
|
||||||
|
>
|
||||||
|
{transcribing ? (
|
||||||
|
<Loader2 size={12} className="spin" />
|
||||||
|
) : (
|
||||||
|
<Mic size={12} />
|
||||||
|
)}
|
||||||
|
{transcribing ? t('logs.live_voice_transcribing') : t('logs.live_voice_transcribe_action')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { useId, useMemo } from 'react'
|
||||||
|
import { joinTimeHHMM, splitTimeHHMM } from '../utils/logEntryPayload.js'
|
||||||
|
import { preferNativeCameraPicker } from '../utils/captureVideoFrame.js'
|
||||||
|
|
||||||
|
const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'))
|
||||||
|
const MINUTES = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'))
|
||||||
|
|
||||||
|
interface EventTimeInput24hProps {
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
disabled?: boolean
|
||||||
|
'aria-label'?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EventTimeInput24h({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled = false,
|
||||||
|
'aria-label': ariaLabel
|
||||||
|
}: EventTimeInput24hProps) {
|
||||||
|
const baseId = useId()
|
||||||
|
const useNativePicker = preferNativeCameraPicker()
|
||||||
|
const { hours, minutes } = useMemo(() => splitTimeHHMM(value), [value])
|
||||||
|
const timeValue = useMemo(() => joinTimeHHMM(hours, minutes), [hours, minutes])
|
||||||
|
|
||||||
|
if (useNativePicker) {
|
||||||
|
return (
|
||||||
|
<div className="time-input-24h">
|
||||||
|
<input
|
||||||
|
id={baseId}
|
||||||
|
type="time"
|
||||||
|
step={60}
|
||||||
|
className="input-text time-input-24h__native"
|
||||||
|
value={timeValue}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = e.target.value
|
||||||
|
if (next) onChange(next.slice(0, 5))
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="time-input-24h">
|
||||||
|
<select
|
||||||
|
id={`${baseId}-hours`}
|
||||||
|
className="input-text time-input-24h__select"
|
||||||
|
value={hours}
|
||||||
|
onChange={(e) => onChange(joinTimeHHMM(e.target.value, minutes))}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={ariaLabel ? `${ariaLabel} (h)` : undefined}
|
||||||
|
>
|
||||||
|
{HOURS.map((hour) => (
|
||||||
|
<option key={hour} value={hour}>
|
||||||
|
{hour}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="time-input-24h__sep" aria-hidden="true">
|
||||||
|
:
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
id={`${baseId}-minutes`}
|
||||||
|
className="input-text time-input-24h__select"
|
||||||
|
value={minutes}
|
||||||
|
onChange={(e) => onChange(joinTimeHHMM(hours, e.target.value))}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={ariaLabel ? `${ariaLabel} (min)` : undefined}
|
||||||
|
>
|
||||||
|
{MINUTES.map((minute) => (
|
||||||
|
<option key={minute} value={minute}>
|
||||||
|
{minute}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -172,6 +172,7 @@ export default function FeedbackModal({
|
|||||||
<option value="general">{t('feedback.category_general')}</option>
|
<option value="general">{t('feedback.category_general')}</option>
|
||||||
<option value="bug">{t('feedback.category_bug')}</option>
|
<option value="bug">{t('feedback.category_bug')}</option>
|
||||||
<option value="feature">{t('feedback.category_feature')}</option>
|
<option value="feature">{t('feedback.category_feature')}</option>
|
||||||
|
<option value="translation">{t('feedback.category_translation')}</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Signal } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
formatGpsAccuracyMeters,
|
||||||
|
gpsQualityI18nKey,
|
||||||
|
type GpsSignalQuality
|
||||||
|
} from '../utils/geolocation.js'
|
||||||
|
|
||||||
|
const SIGNAL_BARS: Record<GpsSignalQuality, number> = {
|
||||||
|
excellent: 4,
|
||||||
|
good: 3,
|
||||||
|
fair: 2,
|
||||||
|
poor: 1,
|
||||||
|
unknown: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GpsSignalHintProps {
|
||||||
|
quality: GpsSignalQuality
|
||||||
|
accuracyM: number | null
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GpsSignalHint({ quality, accuracyM, className = '' }: GpsSignalHintProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const bars = SIGNAL_BARS[quality]
|
||||||
|
const i18nParams = accuracyM != null ? { accuracy: formatGpsAccuracyMeters(accuracyM) } : undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
className={`gps-signal-hint gps-signal-${quality} ${className}`.trim()}
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<span className="gps-signal-hint-label">
|
||||||
|
<Signal size={14} aria-hidden className="gps-signal-icon" />
|
||||||
|
<span className="gps-signal-bars" aria-hidden>
|
||||||
|
{[1, 2, 3, 4].map((level) => (
|
||||||
|
<span
|
||||||
|
key={level}
|
||||||
|
className={`gps-signal-bar ${level <= bars ? 'is-active' : ''}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<span>{t(gpsQualityI18nKey(quality), i18nParams)}</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Ship, LogIn, UserPlus, AlertTriangle, ShieldCheck, Languages, ArrowRight, KeyRound } from 'lucide-react'
|
import LanguageDropdown from './LanguageDropdown.tsx'
|
||||||
|
import { Ship, LogIn, UserPlus, AlertTriangle, ShieldCheck, ArrowRight, KeyRound } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
getActiveMasterKey,
|
getActiveMasterKey,
|
||||||
registerUser,
|
registerUser,
|
||||||
@@ -49,7 +50,7 @@ const hexToBuffer = (hex: string): ArrayBuffer => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function InvitationAcceptance({ onAccepted, onCancel }: InvitationAcceptanceProps) {
|
export default function InvitationAcceptance({ onAccepted, onCancel }: InvitationAcceptanceProps) {
|
||||||
const { t, i18n } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [accepting, setAccepting] = useState(false)
|
const [accepting, setAccepting] = useState(false)
|
||||||
@@ -307,9 +308,6 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
|||||||
setIsLoggedIn(true)
|
setIsLoggedIn(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleLanguage = () => {
|
|
||||||
i18n.changeLanguage(i18n.language.startsWith('de') ? 'en' : 'de')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (recoveryPhrase) {
|
if (recoveryPhrase) {
|
||||||
return (
|
return (
|
||||||
@@ -344,15 +342,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')}
|
||||||
@@ -488,10 +507,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="auth-footer" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '16px', marginTop: '24px' }}>
|
<div className="auth-footer" style={{ borderTop: '1px solid rgba(255,255,255,0.05)', paddingTop: '16px', marginTop: '24px' }}>
|
||||||
<button className="btn-icon-text" onClick={toggleLanguage}>
|
<LanguageDropdown variant="text" align="left" />
|
||||||
<Languages size={18} />
|
|
||||||
{i18n.language.startsWith('de') ? t('invitation.switch_language_en') : t('invitation.switch_language_de')}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Languages, Globe, ChevronDown } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
SUPPORTED_LANGUAGES,
|
||||||
|
changeAppLanguage,
|
||||||
|
normalizeAppLanguage,
|
||||||
|
type AppLanguage
|
||||||
|
} from '../utils/i18nLanguages.js'
|
||||||
|
|
||||||
|
function FlagIcon({ lang, className, style }: { lang: string; className?: string; style?: React.CSSProperties }) {
|
||||||
|
const baseStyle = {
|
||||||
|
display: 'inline-block',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
borderRadius: '2px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.15)',
|
||||||
|
boxSizing: 'border-box' as const,
|
||||||
|
...style
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (lang) {
|
||||||
|
case 'de':
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 5 3" className={className} style={baseStyle}>
|
||||||
|
<rect width="5" height="3" fill="#FFCE00"/>
|
||||||
|
<rect width="5" height="2" fill="#DD0000"/>
|
||||||
|
<rect width="5" height="1" fill="#000000"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'en':
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 60 30" className={className} style={baseStyle}>
|
||||||
|
<clipPath id="union-jack-clip">
|
||||||
|
<path d="M0,0 L60,30 M60,0 L0,30"/>
|
||||||
|
</clipPath>
|
||||||
|
<rect width="60" height="30" fill="#012169"/>
|
||||||
|
<path d="M0,0 L60,30 M60,0 L0,30" stroke="#fff" strokeWidth="6"/>
|
||||||
|
<path d="M0,0 L60,30 M60,0 L0,30" stroke="#C8102E" strokeWidth="4" clipPath="url(#union-jack-clip)"/>
|
||||||
|
<path d="M30,0 v30 M0,15 h60" stroke="#fff" strokeWidth="10"/>
|
||||||
|
<path d="M30,0 v30 M0,15 h60" stroke="#C8102E" strokeWidth="6"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'da':
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 37 28" className={className} style={baseStyle}>
|
||||||
|
<rect width="37" height="28" fill="#C8102E"/>
|
||||||
|
<path d="M12,0 h4 v28 h-4 z M0,12 h37 v4 h-37 z" fill="#FFFFFF"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'sv':
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 16 10" className={className} style={baseStyle}>
|
||||||
|
<rect width="16" height="10" fill="#006AA7"/>
|
||||||
|
<path d="M5,0 h2 v10 h-2 z M0,4 h16 v2 h-16 z" fill="#FECC00"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'nb':
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 22 16" className={className} style={baseStyle}>
|
||||||
|
<rect width="22" height="16" fill="#BA0C2F"/>
|
||||||
|
<path d="M6,0 h4 v16 h-4 z M0,6 h22 v4 h-22 z" fill="#FFFFFF"/>
|
||||||
|
<path d="M7,0 h2 v16 h-2 z M0,7 h22 v2 h-22 z" fill="#00205B"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'fr':
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 3 2" className={className} style={baseStyle}>
|
||||||
|
<rect width="3" height="2" fill="#FFFFFF"/>
|
||||||
|
<rect width="1" height="2" fill="#002395"/>
|
||||||
|
<rect x="2" width="1" height="2" fill="#ED2939"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
case 'es':
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 3 2" className={className} style={baseStyle}>
|
||||||
|
<rect width="3" height="2" fill="#C1272D"/>
|
||||||
|
<rect y="0.5" width="3" height="1" fill="#FEE100"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LanguageDropdownProps {
|
||||||
|
variant?: 'icon' | 'text' | 'secondary-button'
|
||||||
|
align?: 'left' | 'right'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LanguageDropdown({
|
||||||
|
variant = 'icon',
|
||||||
|
align = 'right'
|
||||||
|
}: LanguageDropdownProps) {
|
||||||
|
const { t, i18n } = useTranslation()
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const activeLang = normalizeAppLanguage(i18n.language)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return
|
||||||
|
|
||||||
|
const closeOnOutsideClick = (event: MouseEvent) => {
|
||||||
|
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeOnEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') setIsOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', closeOnOutsideClick)
|
||||||
|
document.addEventListener('keydown', closeOnEscape)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', closeOnOutsideClick)
|
||||||
|
document.removeEventListener('keydown', closeOnEscape)
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
const selectLanguage = (lang: AppLanguage) => {
|
||||||
|
changeAppLanguage(i18n, lang)
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger button content based on variant
|
||||||
|
const renderTriggerContent = () => {
|
||||||
|
const name = t(`languages.${activeLang}`)
|
||||||
|
|
||||||
|
if (variant === 'icon') {
|
||||||
|
return (
|
||||||
|
<span className="lang-dropdown-trigger-flag" aria-hidden="true">
|
||||||
|
<FlagIcon lang={activeLang} className="lang-flag-svg trigger-icon-only" />
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant === 'secondary-button') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Globe size={14} style={{ marginRight: '4px' }} />
|
||||||
|
<FlagIcon lang={activeLang} className="lang-flag-svg" style={{ marginRight: '4px' }} />
|
||||||
|
<span className="lang-trigger-name">{name}</span>
|
||||||
|
<ChevronDown size={12} className="lang-dropdown-chevron" />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default or "text" variant (used in footer)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Languages size={18} />
|
||||||
|
<FlagIcon lang={activeLang} className="lang-flag-svg" style={{ margin: '0 4px' }} />
|
||||||
|
<span>{name}</span>
|
||||||
|
<ChevronDown size={14} className="lang-dropdown-chevron" />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggerClass =
|
||||||
|
variant === 'icon'
|
||||||
|
? 'btn-icon'
|
||||||
|
: variant === 'secondary-button'
|
||||||
|
? 'btn secondary compact'
|
||||||
|
: 'btn-icon-text'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`lang-dropdown ${isOpen ? 'is-open' : ''} align-${align}`}
|
||||||
|
ref={rootRef}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={triggerClass}
|
||||||
|
onClick={() => setIsOpen((prev) => !prev)}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
title="Switch Language"
|
||||||
|
style={variant === 'secondary-button' ? { width: 'auto', padding: '6px 12px', fontSize: '13px' } : undefined}
|
||||||
|
>
|
||||||
|
{renderTriggerContent()}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<ul className="lang-dropdown-menu" role="listbox">
|
||||||
|
{SUPPORTED_LANGUAGES.map((lang) => {
|
||||||
|
const isSelected = lang === activeLang
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={lang}
|
||||||
|
role="option"
|
||||||
|
aria-selected={isSelected}
|
||||||
|
className={`lang-dropdown-option ${isSelected ? 'is-selected' : ''}`}
|
||||||
|
onClick={() => selectLanguage(lang)}
|
||||||
|
>
|
||||||
|
<FlagIcon lang={lang} className="lang-flag-svg" />
|
||||||
|
<span className="lang-option-name">{t(`languages.${lang}`)}</span>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import QRCode from 'qrcode'
|
||||||
|
|
||||||
|
interface LinkQrCodeProps {
|
||||||
|
value: string
|
||||||
|
size?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LinkQrCode({ value, size = 200 }: LinkQrCodeProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [dataUrl, setDataUrl] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!value.trim()) {
|
||||||
|
setDataUrl(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
void QRCode.toDataURL(value, {
|
||||||
|
width: size,
|
||||||
|
margin: 2,
|
||||||
|
errorCorrectionLevel: 'M',
|
||||||
|
color: { dark: '#0f172a', light: '#ffffff' }
|
||||||
|
})
|
||||||
|
.then((url) => {
|
||||||
|
if (!cancelled) setDataUrl(url)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('QR code generation failed:', err)
|
||||||
|
if (!cancelled) setDataUrl(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [value, size])
|
||||||
|
|
||||||
|
if (!value.trim() || !dataUrl) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="link-qr-block">
|
||||||
|
<p className="link-qr-label">{t('settings.link_qr_hint')}</p>
|
||||||
|
<img
|
||||||
|
src={dataUrl}
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
className="link-qr-image"
|
||||||
|
alt={t('settings.link_qr_alt')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Camera, X } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
cameraErrorKeyFromDomException,
|
||||||
|
probeCameraAvailability
|
||||||
|
} from '../utils/cameraAvailability.js'
|
||||||
|
import {
|
||||||
|
captureVideoFrame,
|
||||||
|
preferNativeCameraPicker
|
||||||
|
} from '../utils/captureVideoFrame.js'
|
||||||
|
|
||||||
|
interface LiveCameraCaptureProps {
|
||||||
|
open: boolean
|
||||||
|
busy?: boolean
|
||||||
|
caption?: string
|
||||||
|
onCaptionChange?: (value: string) => void
|
||||||
|
onClose: () => void
|
||||||
|
onCapture: (blob: Blob) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type Phase = 'checking' | 'live' | 'preview' | 'native'
|
||||||
|
|
||||||
|
export default function LiveCameraCapture({
|
||||||
|
open,
|
||||||
|
busy = false,
|
||||||
|
caption = '',
|
||||||
|
onCaptionChange,
|
||||||
|
onClose,
|
||||||
|
onCapture
|
||||||
|
}: LiveCameraCaptureProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const videoRef = useRef<HTMLVideoElement | null>(null)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
|
const streamRef = useRef<MediaStream | null>(null)
|
||||||
|
const previewUrlRef = useRef<string | null>(null)
|
||||||
|
|
||||||
|
const [cameraError, setCameraError] = useState<string | null>(null)
|
||||||
|
const [ready, setReady] = useState(false)
|
||||||
|
const [capturing, setCapturing] = useState(false)
|
||||||
|
const [phase, setPhase] = useState<Phase>('checking')
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||||
|
const [previewBlob, setPreviewBlob] = useState<Blob | null>(null)
|
||||||
|
const [streamGeneration, setStreamGeneration] = useState(0)
|
||||||
|
|
||||||
|
const clearPreview = useCallback(() => {
|
||||||
|
if (previewUrlRef.current) {
|
||||||
|
URL.revokeObjectURL(previewUrlRef.current)
|
||||||
|
previewUrlRef.current = null
|
||||||
|
}
|
||||||
|
setPreviewUrl(null)
|
||||||
|
setPreviewBlob(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const stopStream = useCallback(() => {
|
||||||
|
for (const track of streamRef.current?.getTracks() ?? []) {
|
||||||
|
track.stop()
|
||||||
|
}
|
||||||
|
streamRef.current = null
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.srcObject = null
|
||||||
|
}
|
||||||
|
setReady(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const enterPreview = useCallback((blob: Blob) => {
|
||||||
|
stopStream()
|
||||||
|
clearPreview()
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
previewUrlRef.current = url
|
||||||
|
setPreviewBlob(blob)
|
||||||
|
setPreviewUrl(url)
|
||||||
|
setPhase('preview')
|
||||||
|
}, [stopStream, clearPreview])
|
||||||
|
|
||||||
|
const resetToLive = useCallback(() => {
|
||||||
|
clearPreview()
|
||||||
|
setCameraError(null)
|
||||||
|
setCapturing(false)
|
||||||
|
if (preferNativeCameraPicker()) {
|
||||||
|
setPhase('native')
|
||||||
|
} else {
|
||||||
|
setPhase('live')
|
||||||
|
setStreamGeneration((n) => n + 1)
|
||||||
|
}
|
||||||
|
}, [clearPreview])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
stopStream()
|
||||||
|
clearPreview()
|
||||||
|
setCameraError(null)
|
||||||
|
setCapturing(false)
|
||||||
|
setPhase('checking')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
clearPreview()
|
||||||
|
setCameraError(null)
|
||||||
|
setCapturing(false)
|
||||||
|
setPhase('checking')
|
||||||
|
|
||||||
|
const probe = async () => {
|
||||||
|
const availability = await probeCameraAvailability()
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
|
if (availability === 'unsupported') {
|
||||||
|
setCameraError(t('logs.live_photo_camera_unavailable'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (availability === 'none') {
|
||||||
|
setCameraError(t('logs.live_photo_no_camera'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setPhase(preferNativeCameraPicker() ? 'native' : 'live')
|
||||||
|
}
|
||||||
|
|
||||||
|
void probe()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [open, clearPreview, stopStream, t])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || phase !== 'live') {
|
||||||
|
stopStream()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
const start = async () => {
|
||||||
|
setCameraError(null)
|
||||||
|
setReady(false)
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: {
|
||||||
|
facingMode: { ideal: 'environment' },
|
||||||
|
width: { ideal: 1280 },
|
||||||
|
height: { ideal: 720 }
|
||||||
|
},
|
||||||
|
audio: false
|
||||||
|
})
|
||||||
|
if (cancelled) {
|
||||||
|
for (const track of stream.getTracks()) track.stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
streamRef.current = stream
|
||||||
|
const video = videoRef.current
|
||||||
|
if (!video) return
|
||||||
|
|
||||||
|
const markReady = () => {
|
||||||
|
if (cancelled) return
|
||||||
|
if (video.videoWidth > 0 && video.videoHeight > 0) {
|
||||||
|
setReady(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
video.onloadedmetadata = markReady
|
||||||
|
video.srcObject = stream
|
||||||
|
await video.play()
|
||||||
|
markReady()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Camera access failed:', err)
|
||||||
|
if (!cancelled) {
|
||||||
|
setCameraError(t(cameraErrorKeyFromDomException(err)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void start()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
stopStream()
|
||||||
|
}
|
||||||
|
}, [open, phase, streamGeneration, stopStream, t])
|
||||||
|
|
||||||
|
const handleCapture = async () => {
|
||||||
|
const video = videoRef.current
|
||||||
|
if (!video || !ready || busy || capturing) return
|
||||||
|
|
||||||
|
setCapturing(true)
|
||||||
|
setCameraError(null)
|
||||||
|
try {
|
||||||
|
const blob = await captureVideoFrame(video)
|
||||||
|
enterPreview(blob)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Live camera capture failed:', err)
|
||||||
|
setCameraError(t('logs.live_photo_capture_failed'))
|
||||||
|
} finally {
|
||||||
|
setCapturing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNativeFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
e.target.value = ''
|
||||||
|
if (!file || busy) return
|
||||||
|
|
||||||
|
setCameraError(null)
|
||||||
|
try {
|
||||||
|
enterPreview(file)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Live camera file pick failed:', err)
|
||||||
|
setCameraError(t('logs.live_photo_capture_failed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!previewBlob || busy) return
|
||||||
|
onCapture(previewBlob)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRetake = () => {
|
||||||
|
if (busy) return
|
||||||
|
resetToLive()
|
||||||
|
}
|
||||||
|
|
||||||
|
const openNativePicker = () => {
|
||||||
|
if (busy) return
|
||||||
|
fileInputRef.current?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
const showPreview = phase === 'preview' && previewUrl
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="live-log-modal-backdrop live-camera-backdrop"
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget && !busy) onClose() }}
|
||||||
|
>
|
||||||
|
<div className="live-log-modal live-camera-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="live-camera-header">
|
||||||
|
<h3>{t('logs.live_photo_btn')}</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary live-camera-close"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label={t('logs.live_cancel')}
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
|
className="live-camera-file-input"
|
||||||
|
onChange={(e) => void handleNativeFile(e)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{cameraError && (
|
||||||
|
<p className="live-log-modal-hint auth-error">{cameraError}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showPreview ? (
|
||||||
|
<div className="live-camera-preview-wrap">
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt=""
|
||||||
|
className="live-camera-preview live-camera-preview-still"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : cameraError ? (
|
||||||
|
<div className="live-camera-preview-wrap">
|
||||||
|
<p className="live-camera-loading">{cameraError}</p>
|
||||||
|
</div>
|
||||||
|
) : phase === 'checking' ? (
|
||||||
|
<p className="live-camera-loading">{t('logs.live_photo_camera_starting')}</p>
|
||||||
|
) : phase === 'native' ? (
|
||||||
|
<div className="live-camera-native-prompt">
|
||||||
|
<p className="live-log-modal-hint">{t('logs.live_photo_native_hint')}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn primary live-camera-open-native"
|
||||||
|
onClick={openNativePicker}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
<Camera size={18} />
|
||||||
|
{t('logs.live_photo_open_camera_btn')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : phase === 'live' ? (
|
||||||
|
<div className="live-camera-preview-wrap">
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
className="live-camera-preview"
|
||||||
|
playsInline
|
||||||
|
muted
|
||||||
|
autoPlay
|
||||||
|
/>
|
||||||
|
{!ready && (
|
||||||
|
<p className="live-camera-loading">{t('logs.live_photo_camera_starting')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{onCaptionChange && (
|
||||||
|
<div className="input-group live-camera-caption">
|
||||||
|
<label>{t('logs.photo_caption_label')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input-text"
|
||||||
|
placeholder={t('logs.photo_caption_placeholder')}
|
||||||
|
value={caption}
|
||||||
|
onChange={(e) => onCaptionChange(e.target.value)}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="live-log-modal-actions live-camera-actions">
|
||||||
|
<button type="button" className="btn secondary" onClick={onClose} disabled={busy}>
|
||||||
|
{t('logs.live_cancel')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showPreview ? (
|
||||||
|
<>
|
||||||
|
<button type="button" className="btn secondary" onClick={handleRetake} disabled={busy}>
|
||||||
|
{t('logs.live_photo_retake_btn')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn primary live-camera-shutter"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={busy || !previewBlob}
|
||||||
|
>
|
||||||
|
<Camera size={18} />
|
||||||
|
{busy ? t('logs.photo_processing') : t('logs.live_photo_save_btn')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : phase === 'native' ? null : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn primary live-camera-shutter"
|
||||||
|
onClick={() => void handleCapture()}
|
||||||
|
disabled={busy || capturing || !ready || !!cameraError}
|
||||||
|
>
|
||||||
|
<Camera size={18} />
|
||||||
|
{capturing ? t('logs.photo_processing') : t('logs.live_photo_capture_btn')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,379 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Mic, Square, X } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
assertVoiceMemoBlobSize,
|
||||||
|
formatVoiceDuration,
|
||||||
|
pickMediaRecorderMimeType,
|
||||||
|
VOICE_MEMO_MAX_DURATION_SEC
|
||||||
|
} from '../utils/audioBlob.js'
|
||||||
|
|
||||||
|
interface LiveVoiceCaptureProps {
|
||||||
|
open: boolean
|
||||||
|
busy?: boolean
|
||||||
|
caption?: string
|
||||||
|
onCaptionChange?: (value: string) => void
|
||||||
|
onClose: () => void
|
||||||
|
onSave: (blob: Blob, mimeType: string, durationSec: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type Phase = 'idle' | 'recording' | 'preview'
|
||||||
|
|
||||||
|
export default function LiveVoiceCapture({
|
||||||
|
open,
|
||||||
|
busy = false,
|
||||||
|
caption = '',
|
||||||
|
onCaptionChange,
|
||||||
|
onClose,
|
||||||
|
onSave
|
||||||
|
}: LiveVoiceCaptureProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
||||||
|
const streamRef = useRef<MediaStream | null>(null)
|
||||||
|
const chunksRef = useRef<Blob[]>([])
|
||||||
|
const previewUrlRef = useRef<string | null>(null)
|
||||||
|
const startedAtRef = useRef<number>(0)
|
||||||
|
const timerRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
const [phase, setPhase] = useState<Phase>('idle')
|
||||||
|
const [micError, setMicError] = useState<string | null>(null)
|
||||||
|
const [elapsedSec, setElapsedSec] = useState(0)
|
||||||
|
const [previewBlob, setPreviewBlob] = useState<Blob | null>(null)
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||||
|
const [previewMime, setPreviewMime] = useState('audio/webm')
|
||||||
|
const [previewDurationSec, setPreviewDurationSec] = useState(0)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const log = useCallback((msg: string) => {
|
||||||
|
console.log(`[VoiceDebug] ${msg}`)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const previewAudioRef = useRef<HTMLAudioElement | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = previewAudioRef.current
|
||||||
|
if (!el) {
|
||||||
|
log('previewAudioRef is null')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log('Preview audio player loaded. readyState=' + el.readyState + ', duration=' + el.duration + ', src=' + el.src)
|
||||||
|
|
||||||
|
const handleLoadedMetadata = () => {
|
||||||
|
log('loadedmetadata event fired. readyState=' + el.readyState + ', duration=' + el.duration)
|
||||||
|
if (el.duration === Infinity || isNaN(el.duration) || el.duration === 0) {
|
||||||
|
log('Duration correction hack triggered (duration=' + el.duration + '). Seeking to 1e10...')
|
||||||
|
el.currentTime = 1e10
|
||||||
|
const onTimeUpdate = () => {
|
||||||
|
log('timeupdate event. currentTime=' + el.currentTime + ', duration=' + el.duration)
|
||||||
|
el.currentTime = 0
|
||||||
|
el.removeEventListener('timeupdate', onTimeUpdate)
|
||||||
|
log('currentTime reset to 0. Final duration=' + el.duration)
|
||||||
|
}
|
||||||
|
el.addEventListener('timeupdate', onTimeUpdate)
|
||||||
|
} else {
|
||||||
|
log('Duration correction skipped (duration is valid)')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (el.readyState >= 1) {
|
||||||
|
log('readyState >= 1. Executing hack immediately...')
|
||||||
|
handleLoadedMetadata()
|
||||||
|
} else {
|
||||||
|
log('readyState = 0. Adding loadedmetadata event listener...')
|
||||||
|
el.addEventListener('loadedmetadata', handleLoadedMetadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
log('Calling el.load() to force loading of the media resource...')
|
||||||
|
el.load()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener('loadedmetadata', handleLoadedMetadata)
|
||||||
|
}
|
||||||
|
}, [previewUrl, log])
|
||||||
|
|
||||||
|
const stopStream = useCallback(() => {
|
||||||
|
for (const track of streamRef.current?.getTracks() ?? []) {
|
||||||
|
track.stop()
|
||||||
|
}
|
||||||
|
streamRef.current = null
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const clearPreview = useCallback(() => {
|
||||||
|
if (previewUrlRef.current) {
|
||||||
|
URL.revokeObjectURL(previewUrlRef.current)
|
||||||
|
previewUrlRef.current = null
|
||||||
|
}
|
||||||
|
setPreviewUrl(null)
|
||||||
|
setPreviewBlob(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const clearTimer = useCallback(() => {
|
||||||
|
if (timerRef.current != null) {
|
||||||
|
window.clearInterval(timerRef.current)
|
||||||
|
timerRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const resetAll = useCallback(() => {
|
||||||
|
if (mediaRecorderRef.current?.state === 'recording') {
|
||||||
|
mediaRecorderRef.current.stop()
|
||||||
|
}
|
||||||
|
mediaRecorderRef.current = null
|
||||||
|
chunksRef.current = []
|
||||||
|
clearTimer()
|
||||||
|
stopStream()
|
||||||
|
clearPreview()
|
||||||
|
setPhase('idle')
|
||||||
|
setMicError(null)
|
||||||
|
setElapsedSec(0)
|
||||||
|
setSaving(false)
|
||||||
|
}, [stopStream, clearPreview, clearTimer])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
resetAll()
|
||||||
|
}
|
||||||
|
}, [open, resetAll])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
resetAll()
|
||||||
|
}
|
||||||
|
}, [resetAll])
|
||||||
|
|
||||||
|
const finishRecording = useCallback((blob: Blob, mimeType: string, durationSec: number) => {
|
||||||
|
clearPreview()
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
previewUrlRef.current = url
|
||||||
|
setPreviewBlob(blob)
|
||||||
|
setPreviewUrl(url)
|
||||||
|
setPreviewMime(mimeType)
|
||||||
|
setPreviewDurationSec(durationSec)
|
||||||
|
setPhase('preview')
|
||||||
|
}, [clearPreview])
|
||||||
|
|
||||||
|
const stopRecording = useCallback(() => {
|
||||||
|
const recorder = mediaRecorderRef.current
|
||||||
|
if (!recorder || recorder.state !== 'recording') return
|
||||||
|
recorder.stop()
|
||||||
|
clearTimer()
|
||||||
|
}, [clearTimer])
|
||||||
|
|
||||||
|
const startRecording = async () => {
|
||||||
|
setMicError(null)
|
||||||
|
chunksRef.current = []
|
||||||
|
log('startRecording flow triggered')
|
||||||
|
if (!navigator.mediaDevices?.getUserMedia) {
|
||||||
|
log('navigator.mediaDevices.getUserMedia is unavailable')
|
||||||
|
setMicError(t('logs.live_voice_mic_denied'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
log('Requesting getUserMedia audio stream...')
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||||
|
streamRef.current = stream
|
||||||
|
log('Stream obtained successfully. active=' + stream.active)
|
||||||
|
stream.getTracks().forEach((track, i) => {
|
||||||
|
log(`Track ${i}: label="${track.label}" enabled=${track.enabled} readyState=${track.readyState} muted=${track.muted}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
const mimeType = pickMediaRecorderMimeType()
|
||||||
|
log('MIME type candidates support check:')
|
||||||
|
const MIME_CANDIDATES = [
|
||||||
|
'audio/webm;codecs=opus',
|
||||||
|
'audio/webm',
|
||||||
|
'audio/mp4',
|
||||||
|
'audio/ogg;codecs=opus'
|
||||||
|
]
|
||||||
|
MIME_CANDIDATES.forEach(mime => {
|
||||||
|
log(` - ${mime}: ${MediaRecorder.isTypeSupported(mime) ? 'SUPPORTED' : 'UNSUPPORTED'}`)
|
||||||
|
})
|
||||||
|
log('Selected MIME from picker: ' + mimeType)
|
||||||
|
|
||||||
|
const recorder = mimeType
|
||||||
|
? new MediaRecorder(stream, { mimeType })
|
||||||
|
: new MediaRecorder(stream)
|
||||||
|
mediaRecorderRef.current = recorder
|
||||||
|
const resolvedMime = recorder.mimeType || mimeType || 'audio/webm'
|
||||||
|
log('MediaRecorder created. Resolved mime=' + resolvedMime)
|
||||||
|
|
||||||
|
recorder.ondataavailable = (ev) => {
|
||||||
|
log(`ondataavailable event: data size=${ev.data?.size} bytes`)
|
||||||
|
if (ev.data && ev.data.size > 0) {
|
||||||
|
chunksRef.current.push(ev.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
recorder.onstop = () => {
|
||||||
|
const durationSec = Math.min(
|
||||||
|
VOICE_MEMO_MAX_DURATION_SEC,
|
||||||
|
Math.max(1, Math.round((Date.now() - startedAtRef.current) / 1000))
|
||||||
|
)
|
||||||
|
log(`onstop triggered. durationSec=${durationSec}. Wrapping in 50ms timeout...`)
|
||||||
|
setTimeout(() => {
|
||||||
|
log(`Creating Blob from ${chunksRef.current.length} chunks. Resolved mime=${resolvedMime}`)
|
||||||
|
const totalChunksSize = chunksRef.current.reduce((acc, chunk) => acc + chunk.size, 0)
|
||||||
|
log(`Total raw chunks size: ${totalChunksSize} bytes`)
|
||||||
|
const blob = new Blob(chunksRef.current, { type: resolvedMime })
|
||||||
|
chunksRef.current = []
|
||||||
|
stopStream()
|
||||||
|
log(`Blob finalized: size=${blob.size} bytes, type=${blob.type}`)
|
||||||
|
try {
|
||||||
|
assertVoiceMemoBlobSize(blob)
|
||||||
|
log('Blob size assertion passed. Calling finishRecording...')
|
||||||
|
finishRecording(blob, resolvedMime, durationSec)
|
||||||
|
} catch (err) {
|
||||||
|
log('Blob size assertion failed (too large)')
|
||||||
|
setMicError(t('logs.live_voice_too_large'))
|
||||||
|
setPhase('idle')
|
||||||
|
}
|
||||||
|
}, 50)
|
||||||
|
}
|
||||||
|
|
||||||
|
recorder.onerror = (ev) => {
|
||||||
|
log('MediaRecorder onerror triggered: ' + JSON.stringify(ev))
|
||||||
|
setMicError(t('logs.live_voice_record_failed'))
|
||||||
|
resetAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
startedAtRef.current = Date.now()
|
||||||
|
log('Calling recorder.start()...')
|
||||||
|
recorder.start()
|
||||||
|
log('recorder.start() called. State=' + recorder.state)
|
||||||
|
setPhase('recording')
|
||||||
|
setElapsedSec(0)
|
||||||
|
timerRef.current = window.setInterval(() => {
|
||||||
|
const sec = Math.floor((Date.now() - startedAtRef.current) / 1000)
|
||||||
|
setElapsedSec(sec)
|
||||||
|
if (sec >= VOICE_MEMO_MAX_DURATION_SEC) {
|
||||||
|
log('Max duration reached. Stopping recording...')
|
||||||
|
stopRecording()
|
||||||
|
}
|
||||||
|
}, 250)
|
||||||
|
} catch (err: any) {
|
||||||
|
log('Error in startRecording try-catch block: ' + (err instanceof Error ? err.stack || err.message : String(err)))
|
||||||
|
setMicError(t('logs.live_voice_mic_denied'))
|
||||||
|
stopStream()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!previewBlob || saving || busy) {
|
||||||
|
log('handleSave ignored. previewBlob=' + (previewBlob ? 'PRESENT' : 'NULL') + ' saving=' + saving + ' busy=' + busy)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log('handleSave triggered. Saving blob size=' + previewBlob.size + ' mime=' + previewMime + ' duration=' + previewDurationSec)
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
log('Invoking onSave callback...')
|
||||||
|
await onSave(previewBlob, previewMime, previewDurationSec)
|
||||||
|
log('onSave callback successfully finished!')
|
||||||
|
} catch (err: any) {
|
||||||
|
log('Error during onSave execution: ' + (err instanceof Error ? err.stack || err.message : String(err)))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="live-log-modal-backdrop"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget && !busy && !saving && phase !== 'recording') onClose()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="live-log-modal live-voice-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="live-voice-modal-header">
|
||||||
|
<h3>{t('logs.live_voice_btn')}</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-icon"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={busy || saving || phase === 'recording'}
|
||||||
|
aria-label={t('logs.live_cancel')}
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{micError && <p className="live-log-modal-hint auth-error">{micError}</p>}
|
||||||
|
|
||||||
|
{phase === 'idle' && (
|
||||||
|
<>
|
||||||
|
<p className="live-log-modal-hint">{t('logs.live_voice_hint')}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn primary live-voice-record-btn"
|
||||||
|
onClick={() => void startRecording()}
|
||||||
|
disabled={busy || saving}
|
||||||
|
>
|
||||||
|
<Mic size={18} />
|
||||||
|
{t('logs.live_voice_record')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === 'recording' && (
|
||||||
|
<>
|
||||||
|
<p className="live-voice-recording-indicator" role="status" aria-live="polite">
|
||||||
|
<span className="live-voice-recording-dot" aria-hidden />
|
||||||
|
{t('logs.live_voice_recording', { time: formatVoiceDuration(elapsedSec) })}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn primary live-voice-stop-btn"
|
||||||
|
onClick={stopRecording}
|
||||||
|
>
|
||||||
|
<Square size={16} fill="currentColor" />
|
||||||
|
{t('logs.live_voice_stop')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === 'preview' && previewUrl && (
|
||||||
|
<>
|
||||||
|
<audio ref={previewAudioRef} className="voice-memo-player" controls src={previewUrl} preload="auto" />
|
||||||
|
{onCaptionChange && (
|
||||||
|
<label className="live-voice-caption-field">
|
||||||
|
<span>{t('logs.live_voice_caption_label')}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input-text"
|
||||||
|
value={caption}
|
||||||
|
onChange={(e) => onCaptionChange(e.target.value)}
|
||||||
|
placeholder={t('logs.live_voice_caption_placeholder')}
|
||||||
|
disabled={busy || saving}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div className="live-log-modal-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={() => {
|
||||||
|
clearPreview()
|
||||||
|
setPhase('idle')
|
||||||
|
}}
|
||||||
|
disabled={busy || saving}
|
||||||
|
>
|
||||||
|
{t('logs.live_voice_retake')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn primary"
|
||||||
|
onClick={() => void handleSave()}
|
||||||
|
disabled={busy || saving}
|
||||||
|
>
|
||||||
|
{saving ? t('logs.live_voice_saving') : t('logs.live_voice_save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,16 +3,26 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { db } from '../services/db.js'
|
import { db } from '../services/db.js'
|
||||||
import { getActiveMasterKey } from '../services/auth.js'
|
import { getActiveMasterKey } from '../services/auth.js'
|
||||||
import { getLogbookKey } from '../services/logbookKeys.js'
|
import { getLogbookKey } from '../services/logbookKeys.js'
|
||||||
import { decryptJson, encryptJson } from '../services/crypto.js'
|
import { encryptJson } from '../services/crypto.js'
|
||||||
import { syncLogbook } from '../services/sync.js'
|
import { syncLogbook } from '../services/sync.js'
|
||||||
import { downloadCsv, shareCsv } from '../services/csvExport.js'
|
import { downloadCsv, shareCsv } from '../services/csvExport.js'
|
||||||
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
import { downloadLogbookPagePdf } from '../services/pdfExport.js'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
|
import { getErrorMessage } from '../utils/errors.js'
|
||||||
|
import { findTodayEntryId, pruneEmptyTodayDuplicates, tryDecryptEntryPayload } from '../services/quickEventLog.js'
|
||||||
|
import { localDateString } from '../utils/logEntryPayload.js'
|
||||||
import LogEntryEditor from './LogEntryEditor.tsx'
|
import LogEntryEditor from './LogEntryEditor.tsx'
|
||||||
|
import LiveLogView from './LiveLogView.tsx'
|
||||||
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
|
import EntrySkipperSignBadge from './EntrySkipperSignBadge.tsx'
|
||||||
import { useDialog } from './ModalDialog.tsx'
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
import { getSkipperSignStatus, type SkipperSignStatus } from '../utils/signatures.js'
|
import { getSkipperSignStatus, type SkipperSignStatus } from '../utils/signatures.js'
|
||||||
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2 } from 'lucide-react'
|
import {
|
||||||
|
buildEntryListCache,
|
||||||
|
entryListItemFromLocal,
|
||||||
|
putEntryRecord
|
||||||
|
} from '../utils/entryListCache.js'
|
||||||
|
import { forEachInBatches } from '../utils/yieldToMain.js'
|
||||||
|
import { FileText, Plus, Trash2, ChevronRight, Calendar, Download, Share2, Radio, List } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
carryOverFromPreviousDay,
|
carryOverFromPreviousDay,
|
||||||
compareTravelDaysChronological,
|
compareTravelDaysChronological,
|
||||||
@@ -30,12 +40,15 @@ interface LogEntriesListProps {
|
|||||||
preloadedYacht?: any
|
preloadedYacht?: any
|
||||||
preloadedEntries?: any[]
|
preloadedEntries?: any[]
|
||||||
preloadedPhotos?: any[]
|
preloadedPhotos?: any[]
|
||||||
|
preloadedVoiceMemos?: import('./VoiceMemoPlayer.tsx').PreloadedVoiceMemo[]
|
||||||
preloadedGpsTracks?: any[]
|
preloadedGpsTracks?: any[]
|
||||||
controlledSelectedEntryId?: string | null
|
controlledSelectedEntryId?: string | null
|
||||||
onSelectedEntryIdChange?: (id: string | null) => void
|
onSelectedEntryIdChange?: (id: string | null) => void
|
||||||
highlightEntryId?: string | null
|
highlightEntryId?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LogsViewMode = 'list' | 'live'
|
||||||
|
|
||||||
interface DecryptedEntryItem {
|
interface DecryptedEntryItem {
|
||||||
id: string
|
id: string
|
||||||
date: string
|
date: string
|
||||||
@@ -52,6 +65,7 @@ export default function LogEntriesList({
|
|||||||
preloadedYacht,
|
preloadedYacht,
|
||||||
preloadedEntries,
|
preloadedEntries,
|
||||||
preloadedPhotos,
|
preloadedPhotos,
|
||||||
|
preloadedVoiceMemos,
|
||||||
preloadedGpsTracks,
|
preloadedGpsTracks,
|
||||||
controlledSelectedEntryId,
|
controlledSelectedEntryId,
|
||||||
onSelectedEntryIdChange,
|
onSelectedEntryIdChange,
|
||||||
@@ -75,6 +89,8 @@ export default function LogEntriesList({
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [exporting, setExporting] = useState(false)
|
const [exporting, setExporting] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [viewMode, setViewMode] = useState<LogsViewMode>('list')
|
||||||
|
const [returnToLiveAfterEditor, setReturnToLiveAfterEditor] = useState(false)
|
||||||
const prevSelectedEntryIdRef = useRef<string | null | undefined>(undefined)
|
const prevSelectedEntryIdRef = useRef<string | null | undefined>(undefined)
|
||||||
|
|
||||||
const loadEntries = useCallback(async () => {
|
const loadEntries = useCallback(async () => {
|
||||||
@@ -108,25 +124,40 @@ export default function LogEntriesList({
|
|||||||
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 todayEntryId = await findTodayEntryId(logbookId)
|
||||||
|
if (todayEntryId) {
|
||||||
|
await pruneEmptyTodayDuplicates(logbookId, todayEntryId)
|
||||||
|
}
|
||||||
|
|
||||||
const local = await db.entries.where({ logbookId }).toArray()
|
const local = await db.entries.where({ logbookId }).toArray()
|
||||||
|
|
||||||
const list: DecryptedEntryItem[] = []
|
const list: DecryptedEntryItem[] = []
|
||||||
|
const needsDecrypt: typeof local = []
|
||||||
|
|
||||||
for (const entry of local) {
|
for (const entry of local) {
|
||||||
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
|
const cached = entryListItemFromLocal(entry)
|
||||||
if (decrypted) {
|
if (cached) {
|
||||||
list.push({
|
list.push(cached)
|
||||||
id: entry.payloadId,
|
} else {
|
||||||
date: decrypted.date || '',
|
needsDecrypt.push(entry)
|
||||||
dayOfTravel: decrypted.dayOfTravel || '',
|
|
||||||
departure: decrypted.departure || '',
|
|
||||||
destination: decrypted.destination || '',
|
|
||||||
updatedAt: entry.updatedAt,
|
|
||||||
skipperSignStatus: await getSkipperSignStatus(decrypted as Record<string, unknown>)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await forEachInBatches(needsDecrypt, 8, async (entry) => {
|
||||||
|
const decrypted = await tryDecryptEntryPayload(entry, masterKey)
|
||||||
|
if (!decrypted) return
|
||||||
|
|
||||||
|
const listCache = await buildEntryListCache(decrypted as Record<string, unknown>)
|
||||||
|
list.push({
|
||||||
|
id: entry.payloadId,
|
||||||
|
...listCache,
|
||||||
|
updatedAt: entry.updatedAt
|
||||||
|
})
|
||||||
|
void db.entries.update(entry.payloadId, { listCache }).catch((err) => {
|
||||||
|
console.warn('Failed to persist entry list cache:', err)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// Sort chronological descending (by date, or dayOfTravel numerical)
|
// Sort chronological descending (by date, or dayOfTravel numerical)
|
||||||
list.sort((a, b) => {
|
list.sort((a, b) => {
|
||||||
const dateCompare = new Date(b.date).getTime() - new Date(a.date).getTime()
|
const dateCompare = new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||||
@@ -137,24 +168,26 @@ export default function LogEntriesList({
|
|||||||
setEntries(list)
|
setEntries(list)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to load log entries:', err)
|
console.error('Failed to load log entries:', err)
|
||||||
setError(err.message || 'Decryption failed. Could not load journal list.')
|
setError(getErrorMessage(err, t('errors.load_failed')))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [logbookId, readOnly, preloadedEntries])
|
}, [logbookId, readOnly, preloadedEntries])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (viewMode === 'live') return
|
||||||
loadEntries()
|
loadEntries()
|
||||||
}, [loadEntries])
|
}, [loadEntries, viewMode])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (viewMode === 'live') return
|
||||||
const prevSelectedEntryId = prevSelectedEntryIdRef.current
|
const prevSelectedEntryId = prevSelectedEntryIdRef.current
|
||||||
prevSelectedEntryIdRef.current = selectedEntryId
|
prevSelectedEntryIdRef.current = selectedEntryId
|
||||||
|
|
||||||
if (prevSelectedEntryId !== undefined && prevSelectedEntryId !== null && selectedEntryId === null) {
|
if (prevSelectedEntryId !== undefined && prevSelectedEntryId !== null && selectedEntryId === null) {
|
||||||
loadEntries()
|
loadEntries()
|
||||||
}
|
}
|
||||||
}, [selectedEntryId, loadEntries])
|
}, [selectedEntryId, loadEntries, viewMode])
|
||||||
|
|
||||||
const handleDownloadCsv = async () => {
|
const handleDownloadCsv = async () => {
|
||||||
setExporting(true)
|
setExporting(true)
|
||||||
@@ -169,7 +202,7 @@ export default function LogEntriesList({
|
|||||||
trackPlausibleEvent(PlausibleEvents.CSV_EXPORTED)
|
trackPlausibleEvent(PlausibleEvents.CSV_EXPORTED)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to download CSV:', err)
|
console.error('Failed to download CSV:', err)
|
||||||
setError(err.message || 'Failed to generate CSV export.')
|
setError(getErrorMessage(err, t('errors.export_failed')))
|
||||||
} finally {
|
} finally {
|
||||||
setExporting(false)
|
setExporting(false)
|
||||||
}
|
}
|
||||||
@@ -197,7 +230,7 @@ export default function LogEntriesList({
|
|||||||
setError(t('logs.share_unsupported'))
|
setError(t('logs.share_unsupported'))
|
||||||
} else {
|
} else {
|
||||||
console.error('Failed to share CSV:', err)
|
console.error('Failed to share CSV:', err)
|
||||||
setError(err.message || 'Failed to share CSV export.')
|
setError(getErrorMessage(err, t('errors.export_failed')))
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setExporting(false)
|
setExporting(false)
|
||||||
@@ -218,7 +251,7 @@ export default function LogEntriesList({
|
|||||||
trackPlausibleEvent(PlausibleEvents.PDF_EXPORTED, { scope: 'entry' })
|
trackPlausibleEvent(PlausibleEvents.PDF_EXPORTED, { scope: 'entry' })
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to download PDF:', err)
|
console.error('Failed to download PDF:', err)
|
||||||
setError(err.message || 'Failed to generate PDF export.')
|
setError(getErrorMessage(err, t('errors.export_failed')))
|
||||||
} finally {
|
} finally {
|
||||||
setExporting(false)
|
setExporting(false)
|
||||||
}
|
}
|
||||||
@@ -231,24 +264,31 @@ export default function LogEntriesList({
|
|||||||
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 existingTodayId = await findTodayEntryId(logbookId)
|
||||||
|
if (existingTodayId) {
|
||||||
|
setSelectedEntryId(existingTodayId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const localEntries = await db.entries.where({ logbookId }).toArray()
|
const localEntries = await db.entries.where({ logbookId }).toArray()
|
||||||
const decryptedEntries: Array<LogEntryTankSource & TravelDaySortable> = []
|
const decryptedEntries: Array<LogEntryTankSource & TravelDaySortable> = []
|
||||||
|
|
||||||
for (const entry of localEntries) {
|
for (const entry of localEntries) {
|
||||||
const decrypted = await decryptJson(entry.encryptedData, entry.iv, entry.tag, masterKey)
|
const decrypted = await tryDecryptEntryPayload(entry, masterKey)
|
||||||
if (decrypted) decryptedEntries.push(decrypted as LogEntryTankSource & TravelDaySortable)
|
if (decrypted) decryptedEntries.push(decrypted as LogEntryTankSource & TravelDaySortable)
|
||||||
}
|
}
|
||||||
|
|
||||||
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 +297,7 @@ export default function LogEntriesList({
|
|||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
freshwater = emptyTankLevels()
|
freshwater = emptyTankLevels()
|
||||||
fuel = emptyTankLevels()
|
fuel = emptyTankLevels()
|
||||||
|
greywaterLevel = 0
|
||||||
departure = ''
|
departure = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -265,7 +306,13 @@ export default function LogEntriesList({
|
|||||||
|
|
||||||
const localId = window.crypto.randomUUID()
|
const localId = window.crypto.randomUUID()
|
||||||
const nowStr = new Date().toISOString()
|
const nowStr = new Date().toISOString()
|
||||||
const todayStr = nowStr.substring(0, 10)
|
const todayStr = localDateString()
|
||||||
|
|
||||||
|
const { loadDefaultEntryCrewForNewDay } = await import('./EntryCrewSection.js')
|
||||||
|
const entryCrew = await loadDefaultEntryCrewForNewDay(
|
||||||
|
logbookId,
|
||||||
|
previousEntry as Record<string, unknown> | null
|
||||||
|
)
|
||||||
|
|
||||||
const initialPayload = {
|
const initialPayload = {
|
||||||
date: todayStr,
|
date: todayStr,
|
||||||
@@ -274,6 +321,10 @@ export default function LogEntriesList({
|
|||||||
destination: '',
|
destination: '',
|
||||||
freshwater,
|
freshwater,
|
||||||
fuel,
|
fuel,
|
||||||
|
...(greywaterLevel > 0 ? { greywater: { level: greywaterLevel } } : {}),
|
||||||
|
selectedSkipperId: entryCrew.selectedSkipperId,
|
||||||
|
selectedCrewIds: entryCrew.selectedCrewIds,
|
||||||
|
crewSnapshotsById: entryCrew.crewSnapshotsById,
|
||||||
signSkipper: '',
|
signSkipper: '',
|
||||||
signCrew: '',
|
signCrew: '',
|
||||||
events: []
|
events: []
|
||||||
@@ -282,14 +333,17 @@ export default function LogEntriesList({
|
|||||||
const encrypted = await encryptJson(initialPayload, masterKey)
|
const encrypted = await encryptJson(initialPayload, masterKey)
|
||||||
|
|
||||||
// Save locally
|
// Save locally
|
||||||
await db.entries.put({
|
await putEntryRecord(
|
||||||
payloadId: localId,
|
{
|
||||||
logbookId,
|
payloadId: localId,
|
||||||
encryptedData: encrypted.ciphertext,
|
logbookId,
|
||||||
iv: encrypted.iv,
|
encryptedData: encrypted.ciphertext,
|
||||||
tag: encrypted.tag,
|
iv: encrypted.iv,
|
||||||
updatedAt: nowStr
|
tag: encrypted.tag,
|
||||||
})
|
updatedAt: nowStr
|
||||||
|
},
|
||||||
|
initialPayload
|
||||||
|
)
|
||||||
|
|
||||||
// Queue for background sync
|
// Queue for background sync
|
||||||
await db.syncQueue.put({
|
await db.syncQueue.put({
|
||||||
@@ -307,7 +361,7 @@ export default function LogEntriesList({
|
|||||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to create entry:', err)
|
console.error('Failed to create entry:', err)
|
||||||
setError(err.message || 'Failed to create new log entry.')
|
setError(getErrorMessage(err, t('errors.save_failed')))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -337,7 +391,7 @@ export default function LogEntriesList({
|
|||||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to delete log entry:', err)
|
console.error('Failed to delete log entry:', err)
|
||||||
setError(err.message || 'Failed to delete log entry.')
|
setError(getErrorMessage(err, t('errors.delete_failed')))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -347,15 +401,38 @@ export default function LogEntriesList({
|
|||||||
<LogEntryEditor
|
<LogEntryEditor
|
||||||
entryId={selectedEntryId}
|
entryId={selectedEntryId}
|
||||||
logbookId={logbookId}
|
logbookId={logbookId}
|
||||||
onBack={() => setSelectedEntryId(null)}
|
onBack={() => {
|
||||||
|
setSelectedEntryId(null)
|
||||||
|
if (returnToLiveAfterEditor) {
|
||||||
|
setViewMode('live')
|
||||||
|
setReturnToLiveAfterEditor(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
preloadedEntry={preloadedEntries?.find(entry => (entry.payloadId || entry.id) === selectedEntryId)}
|
preloadedEntry={preloadedEntries?.find(entry => (entry.payloadId || entry.id) === selectedEntryId)}
|
||||||
preloadedPhotos={preloadedPhotos}
|
preloadedPhotos={preloadedPhotos}
|
||||||
|
preloadedVoiceMemos={preloadedVoiceMemos}
|
||||||
preloadedTrack={preloadedGpsTracks?.find(track => track.entryId === selectedEntryId)}
|
preloadedTrack={preloadedGpsTracks?.find(track => track.entryId === selectedEntryId)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (viewMode === 'live' && !readOnly) {
|
||||||
|
return (
|
||||||
|
<LiveLogView
|
||||||
|
logbookId={logbookId}
|
||||||
|
onOpenEditor={(entryId) => {
|
||||||
|
setReturnToLiveAfterEditor(true)
|
||||||
|
setSelectedEntryId(entryId)
|
||||||
|
}}
|
||||||
|
onSwitchToList={() => {
|
||||||
|
setViewMode('list')
|
||||||
|
void loadEntries()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="tab-placeholder">
|
<div className="tab-placeholder">
|
||||||
@@ -365,14 +442,42 @@ 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="logs-journal">
|
||||||
<div className="section-title-bar mb-6">
|
<div className="section-title-bar mb-6">
|
||||||
<div className="form-header" style={{ margin: 0 }}>
|
<div className="form-header" style={{ margin: 0 }}>
|
||||||
<Calendar size={24} className="form-icon" />
|
<Calendar size={24} className="form-icon" />
|
||||||
<h2>{t('logs.title')}</h2>
|
<h2>{t('logs.title')}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="section-toolbar">
|
<div className="section-toolbar">
|
||||||
|
{!readOnly && (
|
||||||
|
<div className="logs-view-toggle" role="group" aria-label={t('logs.view_mode_label')}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn secondary logs-view-toggle-btn ${viewMode === 'list' ? 'is-active' : ''}`}
|
||||||
|
onClick={() => setViewMode('list')}
|
||||||
|
title={t('logs.view_list')}
|
||||||
|
>
|
||||||
|
<List size={16} />
|
||||||
|
<span className="hide-mobile">{t('logs.view_list')}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn secondary logs-view-toggle-btn ${viewMode === 'live' ? 'is-active' : ''}`}
|
||||||
|
onClick={() => setViewMode('live')}
|
||||||
|
title={t('logs.live_mode')}
|
||||||
|
>
|
||||||
|
<Radio size={16} />
|
||||||
|
<span className="hide-mobile">{t('logs.live_mode')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<button className="btn secondary" onClick={handleDownloadCsv} disabled={loading || exporting || entries.length === 0} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.export_csv')}>
|
<button className="btn secondary" onClick={handleDownloadCsv} disabled={loading || exporting || entries.length === 0} style={{ width: 'auto', padding: '8px 16px' }} title={t('logs.export_csv')}>
|
||||||
<Download size={16} />
|
<Download size={16} />
|
||||||
<span className="hide-mobile">{exporting ? t('logs.exporting') : t('logs.export_csv')}</span>
|
<span className="hide-mobile">{exporting ? t('logs.exporting') : t('logs.export_csv')}</span>
|
||||||
@@ -402,10 +507,20 @@ 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)}
|
|
||||||
>
|
>
|
||||||
<div className="card-icon">
|
<button
|
||||||
|
type="button"
|
||||||
|
className="logbook-card-select"
|
||||||
|
onClick={() => setSelectedEntryId(item.id)}
|
||||||
|
aria-label={
|
||||||
|
item.departure && item.destination
|
||||||
|
? `${item.departure} → ${item.destination}, ${t('logs.travel_day_number', { number: item.dayOfTravel })}`
|
||||||
|
: `${t('logs.new_entry')}, ${t('logs.travel_day_number', { number: item.dayOfTravel })}`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="card-icon" aria-hidden>
|
||||||
<FileText size={24} />
|
<FileText size={24} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -417,7 +532,7 @@ export default function LogEntriesList({
|
|||||||
</h3>
|
</h3>
|
||||||
<div className="card-meta">
|
<div className="card-meta">
|
||||||
<span className="sync-badge synced">
|
<span className="sync-badge synced">
|
||||||
{t('logs.day_of_travel')} {item.dayOfTravel}
|
{t('logs.travel_day_number', { number: item.dayOfTravel })}
|
||||||
</span>
|
</span>
|
||||||
<EntrySkipperSignBadge status={item.skipperSignStatus} />
|
<EntrySkipperSignBadge status={item.skipperSignStatus} />
|
||||||
<span className="date-badge">
|
<span className="date-badge">
|
||||||
@@ -426,17 +541,17 @@ export default function LogEntriesList({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button className="btn-pdf" onClick={(e) => handleDownloadPdf(item.id, item.date, e)} title={t('logs.export_pdf')} disabled={exporting}>
|
<div className="logbook-card-right-group">
|
||||||
<Download size={18} />
|
<button className="btn-pdf" onClick={(e) => handleDownloadPdf(item.id, item.date, e)} title={t('logs.export_pdf')} disabled={exporting}>
|
||||||
</button>
|
<Download size={18} />
|
||||||
|
|
||||||
{!readOnly && (
|
|
||||||
<button className="btn-delete" onClick={(e) => handleDelete(item.id, e)} title={t('logs.delete_entry')}>
|
|
||||||
<Trash2 size={18} />
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
{!readOnly && (
|
||||||
|
<button className="btn-delete" onClick={(e) => handleDelete(item.id, e)} title={t('logs.delete_entry')}>
|
||||||
<ChevronRight size={18} style={{ color: '#475569', marginLeft: 'auto' }} />
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<ChevronRight size={18} className="logbook-card-chevron" aria-hidden />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,13 +5,16 @@ import { useDialog } from './ModalDialog.tsx'
|
|||||||
import {
|
import {
|
||||||
downloadBackupBlob,
|
downloadBackupBlob,
|
||||||
exportLogbookBackup,
|
exportLogbookBackup,
|
||||||
|
formatBackupBytes,
|
||||||
parseLogbookBackupFile,
|
parseLogbookBackupFile,
|
||||||
previewLogbookBackup,
|
previewLogbookBackup,
|
||||||
restoreLogbookBackup,
|
restoreLogbookBackup,
|
||||||
type LogbookBackupFile,
|
BACKUP_SIZE_CONFIRM_BYTES,
|
||||||
|
type ParsedLogbookBackup,
|
||||||
type LogbookBackupPreview
|
type LogbookBackupPreview
|
||||||
} from '../services/logbookBackup.js'
|
} from '../services/logbookBackup.js'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
|
import { formatAppDateTime } from '../utils/dateTimeFormat.js'
|
||||||
|
|
||||||
interface LogbookBackupPanelProps {
|
interface LogbookBackupPanelProps {
|
||||||
logbookId: string
|
logbookId: string
|
||||||
@@ -26,6 +29,12 @@ function mapBackupError(code: string, t: (key: string) => string): string {
|
|||||||
return t('settings.backup_not_owner')
|
return t('settings.backup_not_owner')
|
||||||
case 'BACKUP_INVALID_JSON':
|
case 'BACKUP_INVALID_JSON':
|
||||||
return t('settings.backup_invalid_json')
|
return t('settings.backup_invalid_json')
|
||||||
|
case 'BACKUP_INVALID_ARCHIVE':
|
||||||
|
return t('settings.backup_invalid_archive')
|
||||||
|
case 'BACKUP_VERSION_UNSUPPORTED':
|
||||||
|
return t('settings.backup_version_unsupported')
|
||||||
|
case 'BACKUP_WRONG_PASSPHRASE':
|
||||||
|
return t('settings.backup_wrong_passphrase')
|
||||||
case 'BACKUP_INVALID_FORMAT':
|
case 'BACKUP_INVALID_FORMAT':
|
||||||
return t('settings.backup_invalid_format')
|
return t('settings.backup_invalid_format')
|
||||||
case 'BACKUP_NOT_AUTHENTICATED':
|
case 'BACKUP_NOT_AUTHENTICATED':
|
||||||
@@ -41,7 +50,7 @@ function mapBackupError(code: string, t: (key: string) => string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBackupPanelProps) {
|
export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBackupPanelProps) {
|
||||||
const { t } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
const { showConfirm } = useDialog()
|
const { showConfirm } = useDialog()
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
@@ -52,12 +61,16 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
|||||||
const [importPassphrase, setImportPassphrase] = useState('')
|
const [importPassphrase, setImportPassphrase] = useState('')
|
||||||
const [importFile, setImportFile] = useState<File | null>(null)
|
const [importFile, setImportFile] = useState<File | null>(null)
|
||||||
const [importPreview, setImportPreview] = useState<LogbookBackupPreview | null>(null)
|
const [importPreview, setImportPreview] = useState<LogbookBackupPreview | null>(null)
|
||||||
const [parsedBackup, setParsedBackup] = useState<LogbookBackupFile | null>(null)
|
const [parsedBackup, setParsedBackup] = useState<ParsedLogbookBackup | null>(null)
|
||||||
const [importing, setImporting] = useState(false)
|
const [importing, setImporting] = useState(false)
|
||||||
const [previewing, setPreviewing] = useState(false)
|
const [previewing, setPreviewing] = useState(false)
|
||||||
|
const [exportProgress, setExportProgress] = useState<string | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [success, setSuccess] = useState<string | null>(null)
|
const [success, setSuccess] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const exportPassphrasesMatch =
|
||||||
|
exportPassphrase.length >= 8 && exportPassphrase === exportConfirm
|
||||||
|
|
||||||
const handleExportSubmit = async (e: React.FormEvent) => {
|
const handleExportSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
await handleExport()
|
await handleExport()
|
||||||
@@ -82,21 +95,36 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
|||||||
}
|
}
|
||||||
|
|
||||||
setExporting(true)
|
setExporting(true)
|
||||||
|
setExportProgress(null)
|
||||||
try {
|
try {
|
||||||
const { blob, filename, backup } = await exportLogbookBackup(logbookId, exportPassphrase)
|
const { blob, filename, manifest } = await exportLogbookBackup(logbookId, exportPassphrase, {
|
||||||
|
onProgress: (p) => {
|
||||||
|
if (p.phase === 'pack') {
|
||||||
|
setExportProgress(
|
||||||
|
t('settings.backup_export_progress', {
|
||||||
|
current: p.current,
|
||||||
|
total: p.total
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
downloadBackupBlob(blob, filename)
|
downloadBackupBlob(blob, filename)
|
||||||
setSuccess(t('settings.backup_export_success', { count: backup.counts.entries }))
|
setSuccess(t('settings.backup_export_success', { count: manifest.counts.entries }))
|
||||||
setExportPassphrase('')
|
setExportPassphrase('')
|
||||||
setExportConfirm('')
|
setExportConfirm('')
|
||||||
trackPlausibleEvent(PlausibleEvents.BACKUP_EXPORTED, {
|
trackPlausibleEvent(PlausibleEvents.BACKUP_EXPORTED, {
|
||||||
entries: backup.counts.entries,
|
entries: manifest.counts.entries,
|
||||||
photos: backup.counts.photos
|
photos: manifest.counts.photos,
|
||||||
|
voiceMemos: manifest.counts.voiceMemos,
|
||||||
|
bytes: manifest.totalUncompressedBytes
|
||||||
})
|
})
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
setError(mapBackupError(message, t))
|
setError(mapBackupError(message, t))
|
||||||
} finally {
|
} finally {
|
||||||
setExporting(false)
|
setExporting(false)
|
||||||
|
setExportProgress(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,6 +165,18 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
|||||||
const handleRestore = async (options: { overwrite?: boolean; assignNewId?: boolean } = {}) => {
|
const handleRestore = async (options: { overwrite?: boolean; assignNewId?: boolean } = {}) => {
|
||||||
if (!parsedBackup || !importPassphrase) return
|
if (!parsedBackup || !importPassphrase) return
|
||||||
|
|
||||||
|
if (parsedBackup.manifest.totalUncompressedBytes > BACKUP_SIZE_CONFIRM_BYTES) {
|
||||||
|
const ok = await showConfirm(
|
||||||
|
t('settings.backup_import_size_confirm', {
|
||||||
|
size: formatBackupBytes(parsedBackup.manifest.totalUncompressedBytes)
|
||||||
|
}),
|
||||||
|
t('settings.backup_restore_title'),
|
||||||
|
t('logs.confirm_yes'),
|
||||||
|
t('logs.confirm_no')
|
||||||
|
)
|
||||||
|
if (!ok) return
|
||||||
|
}
|
||||||
|
|
||||||
setImporting(true)
|
setImporting(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
@@ -148,8 +188,10 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
|||||||
setParsedBackup(null)
|
setParsedBackup(null)
|
||||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||||
trackPlausibleEvent(PlausibleEvents.BACKUP_RESTORED, {
|
trackPlausibleEvent(PlausibleEvents.BACKUP_RESTORED, {
|
||||||
entries: parsedBackup.counts.entries,
|
entries: parsedBackup.manifest.counts.entries,
|
||||||
photos: parsedBackup.counts.photos,
|
photos: parsedBackup.manifest.counts.photos,
|
||||||
|
voiceMemos: parsedBackup.manifest.counts.voiceMemos,
|
||||||
|
bytes: parsedBackup.manifest.totalUncompressedBytes,
|
||||||
mode: options.overwrite ? 'overwrite' : options.assignNewId ? 'new_id' : 'same_id'
|
mode: options.overwrite ? 'overwrite' : options.assignNewId ? 'new_id' : 'same_id'
|
||||||
})
|
})
|
||||||
onRestored?.(result.logbookId, result.title)
|
onRestored?.(result.logbookId, result.title)
|
||||||
@@ -252,11 +294,16 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn primary"
|
className="btn primary"
|
||||||
disabled={exporting || !exportPassphrase || !exportConfirm}
|
disabled={exporting || !exportPassphrasesMatch}
|
||||||
>
|
>
|
||||||
<Download size={16} />
|
<Download size={16} />
|
||||||
{exporting ? t('settings.backup_exporting') : t('settings.backup_export_btn')}
|
{exporting ? t('settings.backup_exporting') : t('settings.backup_export_btn')}
|
||||||
</button>
|
</button>
|
||||||
|
{exportProgress && (
|
||||||
|
<p className="text-muted backup-export-progress" role="status">
|
||||||
|
{exportProgress}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -274,7 +321,7 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
|||||||
id="backup-import-file"
|
id="backup-import-file"
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept=".daagbok.json,application/json"
|
accept=".daagbok,application/zip"
|
||||||
className="input-text"
|
className="input-text"
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
disabled={importing}
|
disabled={importing}
|
||||||
@@ -329,12 +376,18 @@ export default function LogbookBackupPanel({ logbookId, onRestored }: LogbookBac
|
|||||||
<ul className="backup-preview-stats">
|
<ul className="backup-preview-stats">
|
||||||
<li>{t('settings.backup_stat_entries', { count: importPreview.counts.entries })}</li>
|
<li>{t('settings.backup_stat_entries', { count: importPreview.counts.entries })}</li>
|
||||||
<li>{t('settings.backup_stat_photos', { count: importPreview.counts.photos })}</li>
|
<li>{t('settings.backup_stat_photos', { count: importPreview.counts.photos })}</li>
|
||||||
|
<li>{t('settings.backup_stat_voice', { count: importPreview.counts.voiceMemos })}</li>
|
||||||
<li>{t('settings.backup_stat_crew', { count: importPreview.counts.crews })}</li>
|
<li>{t('settings.backup_stat_crew', { count: importPreview.counts.crews })}</li>
|
||||||
<li>{t('settings.backup_stat_tracks', { count: importPreview.counts.gpsTracks })}</li>
|
<li>{t('settings.backup_stat_tracks', { count: importPreview.counts.gpsTracks })}</li>
|
||||||
|
<li className="text-muted">
|
||||||
|
{t('settings.backup_stat_size', {
|
||||||
|
size: formatBackupBytes(importPreview.totalUncompressedBytes)
|
||||||
|
})}
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p className="text-muted backup-preview-date">
|
<p className="text-muted backup-preview-date">
|
||||||
{t('settings.backup_exported_at', {
|
{t('settings.backup_exported_at', {
|
||||||
date: new Date(importPreview.exportedAt).toLocaleString()
|
date: formatAppDateTime(importPreview.exportedAt, i18n.language)
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Users, User, Save, Check } from 'lucide-react'
|
||||||
|
import type { LogbookCrewSelectionData, PersonSnapshot } from '../types/person.js'
|
||||||
|
import type { DecryptedPerson } from '../services/personPool.js'
|
||||||
|
import { loadPersonPool, filterSkippers, filterCrew } from '../services/personPool.js'
|
||||||
|
import { loadLogbookCrewSelection, saveLogbookCrewSelectionFromIds } from '../services/logbookCrewSelection.js'
|
||||||
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
|
|
||||||
|
export interface LogbookCrewPickerProps {
|
||||||
|
logbookId: string
|
||||||
|
readOnly?: boolean
|
||||||
|
/** Demo / share: in-memory pool */
|
||||||
|
preloadedPool?: Array<{ payloadId: string; data: DecryptedPerson['data'] }>
|
||||||
|
preloadedSelection?: LogbookCrewSelectionData
|
||||||
|
/** Shared logbook: only people from selection snapshots */
|
||||||
|
selectionOnly?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotsToPoolList(
|
||||||
|
selection: LogbookCrewSelectionData
|
||||||
|
): Array<{ payloadId: string; data: DecryptedPerson['data'] }> {
|
||||||
|
return Object.values(selection.snapshotsById).map((snap) => ({
|
||||||
|
payloadId: snap.id,
|
||||||
|
data: {
|
||||||
|
name: snap.name,
|
||||||
|
address: snap.address,
|
||||||
|
birthDate: snap.birthDate,
|
||||||
|
phone: snap.phone,
|
||||||
|
nationality: snap.nationality,
|
||||||
|
passportNumber: snap.passportNumber,
|
||||||
|
bloodType: snap.bloodType,
|
||||||
|
allergies: snap.allergies,
|
||||||
|
diseases: snap.diseases,
|
||||||
|
role: snap.role,
|
||||||
|
photo: snap.photo
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LogbookCrewPicker({
|
||||||
|
logbookId,
|
||||||
|
readOnly = false,
|
||||||
|
preloadedPool,
|
||||||
|
preloadedSelection,
|
||||||
|
selectionOnly = false
|
||||||
|
}: LogbookCrewPickerProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [loading, setLoading] = useState(!preloadedSelection)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [saved, setSaved] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [pool, setPool] = useState<DecryptedPerson[]>([])
|
||||||
|
const [activeSkipperId, setActiveSkipperId] = useState<string | null>(null)
|
||||||
|
const [activeCrewIds, setActiveCrewIds] = useState<string[]>([])
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const selection =
|
||||||
|
preloadedSelection ??
|
||||||
|
(logbookId === 'demo' ? null : await loadLogbookCrewSelection(logbookId))
|
||||||
|
|
||||||
|
if (selection) {
|
||||||
|
setActiveSkipperId(selection.activeSkipperId)
|
||||||
|
setActiveCrewIds([...selection.activeCrewIds])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preloadedPool) {
|
||||||
|
setPool(
|
||||||
|
preloadedPool.map((p) => ({
|
||||||
|
payloadId: p.payloadId,
|
||||||
|
data: p.data
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
} else if (selectionOnly && selection) {
|
||||||
|
setPool(snapshotsToPoolList(selection))
|
||||||
|
} else {
|
||||||
|
setPool(await loadPersonPool())
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load crew selection')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [logbookId, preloadedPool, preloadedSelection, selectionOnly])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadData()
|
||||||
|
}, [loadData])
|
||||||
|
|
||||||
|
const skippers = useMemo(() => filterSkippers(pool), [pool])
|
||||||
|
const crewMembers = useMemo(() => filterCrew(pool), [pool])
|
||||||
|
|
||||||
|
const toggleCrew = (id: string) => {
|
||||||
|
if (readOnly) return
|
||||||
|
setActiveCrewIds((prev) =>
|
||||||
|
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (readOnly || logbookId === 'demo') return
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
setSaved(false)
|
||||||
|
try {
|
||||||
|
await saveLogbookCrewSelectionFromIds(logbookId, activeSkipperId, activeCrewIds)
|
||||||
|
setSaved(true)
|
||||||
|
trackPlausibleEvent(PlausibleEvents.CREW_SAVED, { context: 'logbook_selection' })
|
||||||
|
setTimeout(() => setSaved(false), 3000)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to save')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="tab-placeholder">
|
||||||
|
<Users className="header-logo spin" size={48} />
|
||||||
|
<p>{t('person_pool.loading')}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="crew-dashboard-layout" data-tour="logbook-crew-picker">
|
||||||
|
<div className="form-card">
|
||||||
|
<div className="form-header">
|
||||||
|
<Users size={24} className="form-icon" />
|
||||||
|
<h2>{t('logbook_crew.title')}</h2>
|
||||||
|
</div>
|
||||||
|
<p className="help-text mb-4">{t('logbook_crew.subtitle')}</p>
|
||||||
|
{selectionOnly && <p className="help-text mb-4">{t('logbook_crew.selection_only_hint')}</p>}
|
||||||
|
{error && <div className="auth-error mb-4">{error}</div>}
|
||||||
|
|
||||||
|
<div className="input-group mb-4">
|
||||||
|
<label>{t('logbook_crew.active_skipper')}</label>
|
||||||
|
{skippers.length === 0 ? (
|
||||||
|
<p className="help-text">{t('logbook_crew.no_skippers_in_pool')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="crew-selection-list">
|
||||||
|
{skippers.map((s) => (
|
||||||
|
<label key={s.payloadId} className="crew-selection-item">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`skipper-${logbookId}`}
|
||||||
|
checked={activeSkipperId === s.payloadId}
|
||||||
|
onChange={() => !readOnly && setActiveSkipperId(s.payloadId)}
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
<User size={16} aria-hidden="true" />
|
||||||
|
<span>{s.data.name || t('logbook_crew.unnamed')}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{!readOnly && (
|
||||||
|
<label className="crew-selection-item">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`skipper-${logbookId}`}
|
||||||
|
checked={activeSkipperId === null}
|
||||||
|
onChange={() => setActiveSkipperId(null)}
|
||||||
|
/>
|
||||||
|
<span>{t('logbook_crew.no_skipper')}</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="input-group mb-4">
|
||||||
|
<label>{t('logbook_crew.active_crew')}</label>
|
||||||
|
{crewMembers.length === 0 ? (
|
||||||
|
<p className="help-text">{t('logbook_crew.no_crew_in_pool')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="crew-selection-list">
|
||||||
|
{crewMembers.map((c) => (
|
||||||
|
<label key={c.payloadId} className="crew-selection-item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={activeCrewIds.includes(c.payloadId)}
|
||||||
|
onChange={() => toggleCrew(c.payloadId)}
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
<span>{c.data.name || t('logbook_crew.unnamed')}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!readOnly && logbookId !== 'demo' && (
|
||||||
|
<div className="form-actions">
|
||||||
|
{saved && (
|
||||||
|
<div className="success-toast">
|
||||||
|
<Check size={16} />
|
||||||
|
<span>{t('logbook_crew.saved')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button type="button" className="btn primary" onClick={() => void handleSave()} disabled={saving}>
|
||||||
|
<Save size={18} />
|
||||||
|
{t('logbook_crew.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectionFromSnapshots(
|
||||||
|
snapshotsById: Record<string, PersonSnapshot>
|
||||||
|
): LogbookCrewSelectionData {
|
||||||
|
const snapshots = Object.values(snapshotsById)
|
||||||
|
const skipper = snapshots.find((s) => s.role === 'skipper')
|
||||||
|
return {
|
||||||
|
activeSkipperId: skipper?.id ?? null,
|
||||||
|
activeCrewIds: snapshots.filter((s) => s.role === 'crew').map((s) => s.id),
|
||||||
|
snapshotsById
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,36 +1,74 @@
|
|||||||
import React, { useState, useEffect } 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 LanguageDropdown from './LanguageDropdown.tsx'
|
||||||
import { db } from '../services/db.js'
|
import { useSyncIndicator } from '../hooks/useSyncIndicator.js'
|
||||||
import { fetchLogbooks, createLogbook, deleteLogbook, type DecryptedLogbook } from '../services/logbook.js'
|
import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type DecryptedLogbook } from '../services/logbook.js'
|
||||||
|
import { loadLogbookSearchFieldsBatch } from '../services/logbookSearchIndex.js'
|
||||||
|
import { logbookMatchesFilter, type LogbookSearchFields } from '../utils/logbookFilter.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 { getErrorMessage } from '../utils/errors.js'
|
||||||
import { logoutUser } from '../services/auth.js'
|
import { logoutUser } from '../services/auth.js'
|
||||||
import { useDialog } from './ModalDialog.tsx'
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
import AccountDangerZone from './AccountDangerZone.tsx'
|
import { BookOpen, Plus, Trash2, LogOut, RefreshCw, Ship, Wifi, WifiOff, Search, X, CalendarDays, CaseSensitive, ArrowUp, ArrowDown } from 'lucide-react'
|
||||||
import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, User, Wifi, WifiOff } from 'lucide-react'
|
|
||||||
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
|
import DisclaimerHeaderButton from './DisclaimerHeaderButton.tsx'
|
||||||
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
|
import FeedbackHeaderButton from './FeedbackHeaderButton.tsx'
|
||||||
|
import ProfileHeaderButton from './ProfileHeaderButton.tsx'
|
||||||
|
import AdminHeaderButton from './AdminHeaderButton.tsx'
|
||||||
|
|
||||||
interface LogbookDashboardProps {
|
interface LogbookDashboardProps {
|
||||||
onSelectLogbook: (id: string, title: string) => void
|
onSelectLogbook: (id: string, title: string) => void
|
||||||
onLogout: () => void
|
onLogout: () => void
|
||||||
|
onOpenProfile: () => void
|
||||||
|
onOpenAdmin?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookDashboardProps) {
|
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) => {
|
||||||
|
let cmp = 0
|
||||||
|
if (sortBy === 'name') {
|
||||||
|
cmp = a.title.localeCompare(b.title, locale, { sensitivity: 'base' })
|
||||||
|
} else {
|
||||||
|
const timeA = a.lastTravelDate ? new Date(a.lastTravelDate).getTime() : new Date(a.updatedAt).getTime()
|
||||||
|
const timeB = b.lastTravelDate ? new Date(b.lastTravelDate).getTime() : new Date(b.updatedAt).getTime()
|
||||||
|
cmp = timeA - timeB
|
||||||
|
}
|
||||||
|
return direction === 'asc' ? cmp : -cmp
|
||||||
|
})
|
||||||
|
return sorted
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProfile, onOpenAdmin }: LogbookDashboardProps) {
|
||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
const { showConfirm } = useDialog()
|
const { showConfirm } = useDialog()
|
||||||
const [logbooks, setLogbooks] = useState<DecryptedLogbook[]>([])
|
const [logbooks, setLogbooks] = useState<DecryptedLogbook[]>([])
|
||||||
const [newTitle, setNewTitle] = useState('')
|
const [newTitle, setNewTitle] = useState('')
|
||||||
|
const [editingLogbookId, setEditingLogbookId] = useState<string | null>(null)
|
||||||
|
const [editingTitleDraft, setEditingTitleDraft] = useState('')
|
||||||
|
const titleInputRef = useRef<HTMLInputElement>(null)
|
||||||
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 [searchFieldsByLogbookId, setSearchFieldsByLogbookId] = useState<Map<string, LogbookSearchFields>>(
|
||||||
|
() => new Map()
|
||||||
|
)
|
||||||
|
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')
|
|
||||||
|
|
||||||
// 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(() => {
|
||||||
@@ -49,6 +87,23 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
|||||||
loadLogbooks()
|
loadLogbooks()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const ids = logbooks.map((lb) => lb.id)
|
||||||
|
if (ids.length === 0) {
|
||||||
|
setSearchFieldsByLogbookId(new Map())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
void loadLogbookSearchFieldsBatch(ids).then((index) => {
|
||||||
|
if (!cancelled) setSearchFieldsByLogbookId(index)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [logbooks])
|
||||||
|
|
||||||
const loadLogbooks = async (isRefresh = false) => {
|
const loadLogbooks = async (isRefresh = false) => {
|
||||||
if (isRefresh) setRefreshing(true)
|
if (isRefresh) setRefreshing(true)
|
||||||
else setLoading(true)
|
else setLoading(true)
|
||||||
@@ -56,8 +111,8 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
|||||||
try {
|
try {
|
||||||
const data = await fetchLogbooks()
|
const data = await fetchLogbooks()
|
||||||
setLogbooks(data)
|
setLogbooks(data)
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message || 'Failed to load logbooks')
|
setError(getErrorMessage(err, t('errors.load_failed')))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
setRefreshing(false)
|
setRefreshing(false)
|
||||||
@@ -75,8 +130,8 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
|||||||
setLogbooks((prev) => [created, ...prev])
|
setLogbooks((prev) => [created, ...prev])
|
||||||
setNewTitle('')
|
setNewTitle('')
|
||||||
trackPlausibleEvent(PlausibleEvents.LOGBOOK_CREATED)
|
trackPlausibleEvent(PlausibleEvents.LOGBOOK_CREATED)
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
setError(err.message || 'Failed to create logbook')
|
setError(getErrorMessage(err, t('errors.save_failed')))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -92,39 +147,142 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
|||||||
await deleteLogbook(id)
|
await deleteLogbook(id)
|
||||||
setLogbooks((prev) => prev.filter((lb) => lb.id !== id))
|
setLogbooks((prev) => prev.filter((lb) => lb.id !== id))
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Failed to delete logbook')
|
setError(getErrorMessage(err, t('errors.delete_failed')))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingLogbookId) {
|
||||||
|
titleInputRef.current?.focus()
|
||||||
|
titleInputRef.current?.select()
|
||||||
|
}
|
||||||
|
}, [editingLogbookId])
|
||||||
|
|
||||||
|
const startTitleEdit = (lb: DecryptedLogbook, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setEditingLogbookId(lb.id)
|
||||||
|
setEditingTitleDraft(lb.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelTitleEdit = () => {
|
||||||
|
setEditingLogbookId(null)
|
||||||
|
setEditingTitleDraft('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const commitTitleEdit = async (id: string) => {
|
||||||
|
if (editingLogbookId !== id) return
|
||||||
|
|
||||||
|
const lb = logbooks.find((item) => item.id === id)
|
||||||
|
const trimmedTitle = editingTitleDraft.trim()
|
||||||
|
cancelTitleEdit()
|
||||||
|
|
||||||
|
if (!lb || !trimmedTitle || trimmedTitle === lb.title.trim()) return
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await updateLogbookTitle(id, trimmedTitle)
|
||||||
|
setLogbooks((prev) =>
|
||||||
|
prev.map((item) =>
|
||||||
|
item.id === id ? { ...item, title: trimmedTitle, updatedAt: new Date().toISOString() } : item
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(getErrorMessage(err, t('errors.save_failed')))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
void logoutUser()
|
void logoutUser()
|
||||||
onLogout()
|
onLogout()
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleLanguage = () => {
|
|
||||||
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
|
|
||||||
i18n.changeLanguage(nextLang)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 renderLogbookCard = (lb: DecryptedLogbook) => (
|
const filterActive = filterQuery.trim().length > 0
|
||||||
|
const filteredOwnedLogbooks = useMemo(
|
||||||
|
() =>
|
||||||
|
ownedLogbooks.filter((lb) =>
|
||||||
|
logbookMatchesFilter(lb, filterQuery, i18n.language, searchFieldsByLogbookId.get(lb.id))
|
||||||
|
),
|
||||||
|
[ownedLogbooks, filterQuery, i18n.language, searchFieldsByLogbookId]
|
||||||
|
)
|
||||||
|
const filteredSharedLogbooks = useMemo(
|
||||||
|
() =>
|
||||||
|
sharedLogbooks.filter((lb) =>
|
||||||
|
logbookMatchesFilter(lb, filterQuery, i18n.language, searchFieldsByLogbookId.get(lb.id))
|
||||||
|
),
|
||||||
|
[sharedLogbooks, filterQuery, i18n.language, searchFieldsByLogbookId]
|
||||||
|
)
|
||||||
|
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 isEditingTitle = editingLogbookId === lb.id
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={lb.id}
|
key={lb.id}
|
||||||
className={`logbook-card glass${lb.isShared ? ' logbook-card--shared' : ''}`}
|
className={`logbook-card glass${lb.isShared ? ' logbook-card--shared' : ''}${isEditingTitle ? ' logbook-card--editing-title' : ''}`}
|
||||||
onClick={() => onSelectLogbook(lb.id, lb.title)}
|
|
||||||
>
|
>
|
||||||
<div className="card-icon">
|
{!isEditingTitle && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="logbook-card-select"
|
||||||
|
onClick={() => onSelectLogbook(lb.id, lb.title)}
|
||||||
|
aria-label={t('dashboard.open_logbook', { title: lb.title })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card-icon" aria-hidden>
|
||||||
<BookOpen size={24} />
|
<BookOpen size={24} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card-info">
|
<div className="card-info">
|
||||||
<div className="card-title-row">
|
<div className="card-title-row">
|
||||||
<h3>{lb.title}</h3>
|
{isEditingTitle ? (
|
||||||
|
<input
|
||||||
|
ref={titleInputRef}
|
||||||
|
type="text"
|
||||||
|
className="logbook-title-inline-edit input-text"
|
||||||
|
value={editingTitleDraft}
|
||||||
|
onChange={(e) => setEditingTitleDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
void commitTitleEdit(lb.id)
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
cancelTitleEdit()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={() => void commitTitleEdit(lb.id)}
|
||||||
|
disabled={loading}
|
||||||
|
aria-label={t('dashboard.edit_title')}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<h3
|
||||||
|
className={lb.isShared ? undefined : 'logbook-title-editable'}
|
||||||
|
onClick={lb.isShared ? undefined : (e) => startTitleEdit(lb, e)}
|
||||||
|
title={lb.isShared ? undefined : t('dashboard.edit_title')}
|
||||||
|
>
|
||||||
|
{lb.title}
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
<LogbookRoleBadge role={lb.accessRole} />
|
<LogbookRoleBadge role={lb.accessRole} />
|
||||||
</div>
|
</div>
|
||||||
<div className="card-meta">
|
<div className="card-meta">
|
||||||
@@ -134,8 +292,12 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
|||||||
{lb.isDemo && (
|
{lb.isDemo && (
|
||||||
<span className="demo-badge">{t('demo.badge')}</span>
|
<span className="demo-badge">{t('demo.badge')}</span>
|
||||||
)}
|
)}
|
||||||
|
<span className="entry-count-badge" title={t('dashboard.travel_days_count', { count: lb.entryCount ?? 0 })}>
|
||||||
|
<CalendarDays size={12} style={{ marginRight: '4px' }} />
|
||||||
|
{lb.entryCount ?? 0}
|
||||||
|
</span>
|
||||||
<span className="date-badge">
|
<span className="date-badge">
|
||||||
{new Date(lb.updatedAt).toLocaleDateString(i18n.language, {
|
{new Date(lb.lastTravelDate || lb.updatedAt).toLocaleDateString(i18n.language, {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric'
|
day: 'numeric'
|
||||||
@@ -144,16 +306,22 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
{!lb.isShared && (
|
||||||
className="btn-delete"
|
<div className="logbook-card-actions">
|
||||||
onClick={(e) => handleDelete(lb.id, e)}
|
<button
|
||||||
title={t('dashboard.delete_btn')}
|
type="button"
|
||||||
style={{ visibility: lb.isShared ? 'hidden' : 'visible' }}
|
className="btn-delete"
|
||||||
>
|
onClick={(e) => handleDelete(lb.id, e)}
|
||||||
<Trash2 size={18} />
|
title={t('dashboard.delete_btn')}
|
||||||
</button>
|
aria-label={t('dashboard.delete_btn')}
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const renderLogbookSection = (
|
const renderLogbookSection = (
|
||||||
title: string,
|
title: string,
|
||||||
@@ -188,11 +356,27 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
|||||||
|
|
||||||
<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>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -209,20 +393,11 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Skipper profile */}
|
<ProfileHeaderButton onClick={onOpenProfile} />
|
||||||
<div
|
|
||||||
className="skipper-badge"
|
|
||||||
title={t('dashboard.logged_in_as', { name: username })}
|
|
||||||
aria-label={t('dashboard.logged_in_as', { name: username })}
|
|
||||||
>
|
|
||||||
<User size={16} aria-hidden="true" />
|
|
||||||
<span className="skipper-badge__name">{username}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Lang toggle */}
|
{onOpenAdmin && <AdminHeaderButton onClick={onOpenAdmin} />}
|
||||||
<button className="btn-icon" onClick={toggleLanguage} title="Switch Language">
|
|
||||||
<Languages size={18} />
|
<LanguageDropdown variant="icon" align="right" />
|
||||||
</button>
|
|
||||||
|
|
||||||
<DisclaimerHeaderButton />
|
<DisclaimerHeaderButton />
|
||||||
|
|
||||||
@@ -275,24 +450,118 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
|
|||||||
) : 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>
|
||||||
|
|
||||||
<section className="dashboard-account-section" aria-label={t('settings.danger_zone_title')}>
|
|
||||||
<AccountDangerZone />
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Ship, Save, Check } from 'lucide-react'
|
||||||
|
import type { LogbookVesselSelectionData, VesselData } from '../types/vessel.js'
|
||||||
|
import type { DecryptedVessel } from '../services/vesselPool.js'
|
||||||
|
import { loadVesselPool } from '../services/vesselPool.js'
|
||||||
|
import { loadLogbookVesselSelection, saveLogbookVesselSelectionFromId } from '../services/logbookVesselSelection.js'
|
||||||
|
import { resolveVesselForLogbook } from '../services/resolveVessel.js'
|
||||||
|
import { vesselDataFromSnapshot } from '../utils/vesselSnapshot.js'
|
||||||
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
|
|
||||||
|
export interface LogbookVesselPickerProps {
|
||||||
|
logbookId: string
|
||||||
|
readOnly?: boolean
|
||||||
|
preloadedPool?: Array<{ payloadId: string; data: VesselData }>
|
||||||
|
preloadedSelection?: LogbookVesselSelectionData
|
||||||
|
selectionOnly?: boolean
|
||||||
|
onOpenProfile?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LogbookVesselPicker({
|
||||||
|
logbookId,
|
||||||
|
readOnly = false,
|
||||||
|
preloadedPool,
|
||||||
|
preloadedSelection,
|
||||||
|
selectionOnly = false,
|
||||||
|
onOpenProfile
|
||||||
|
}: LogbookVesselPickerProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [loading, setLoading] = useState(!preloadedSelection)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [saved, setSaved] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [pool, setPool] = useState<DecryptedVessel[]>([])
|
||||||
|
const [activeVesselId, setActiveVesselId] = useState<string | null>(null)
|
||||||
|
const [resolvedVessel, setResolvedVessel] = useState<VesselData | null>(null)
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const selection =
|
||||||
|
preloadedSelection ??
|
||||||
|
(logbookId === 'demo' ? null : await loadLogbookVesselSelection(logbookId))
|
||||||
|
|
||||||
|
if (selection) {
|
||||||
|
setActiveVesselId(selection.activeVesselId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preloadedPool) {
|
||||||
|
setPool(preloadedPool.map((p) => ({ payloadId: p.payloadId, data: p.data })))
|
||||||
|
} else if (selectionOnly && selection?.vesselSnapshot) {
|
||||||
|
const data = vesselDataFromSnapshot(selection.vesselSnapshot)
|
||||||
|
if (data) {
|
||||||
|
setPool([{ payloadId: selection.vesselSnapshot.id, data }])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setPool(await loadVesselPool())
|
||||||
|
}
|
||||||
|
|
||||||
|
const vessel = await resolveVesselForLogbook(logbookId, {
|
||||||
|
preloadedSelection: selection ?? undefined
|
||||||
|
})
|
||||||
|
setResolvedVessel(vessel)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load vessel selection')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [logbookId, preloadedPool, preloadedSelection, selectionOnly])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadData()
|
||||||
|
}, [loadData])
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (readOnly || logbookId === 'demo') return
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
setSaved(false)
|
||||||
|
try {
|
||||||
|
const selection = await saveLogbookVesselSelectionFromId(logbookId, activeVesselId)
|
||||||
|
const vessel = vesselDataFromSnapshot(selection.vesselSnapshot)
|
||||||
|
setResolvedVessel(vessel)
|
||||||
|
setSaved(true)
|
||||||
|
trackPlausibleEvent(PlausibleEvents.VESSEL_SAVED, { context: 'logbook_selection' })
|
||||||
|
setTimeout(() => setSaved(false), 3000)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to save')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="tab-placeholder">
|
||||||
|
<Ship className="header-logo spin" size={48} />
|
||||||
|
<p>{t('vessel_pool.loading')}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="crew-dashboard-layout" data-tour="logbook-vessel-picker">
|
||||||
|
<div className="form-card">
|
||||||
|
<div className="form-header">
|
||||||
|
<Ship size={24} className="form-icon" />
|
||||||
|
<h2>{t('logbook_vessel.title')}</h2>
|
||||||
|
</div>
|
||||||
|
<p className="help-text mb-4">{t('logbook_vessel.subtitle')}</p>
|
||||||
|
{selectionOnly && <p className="help-text mb-4">{t('logbook_vessel.selection_only_hint')}</p>}
|
||||||
|
{!selectionOnly && !readOnly && onOpenProfile && (
|
||||||
|
<p className="help-text mb-4">
|
||||||
|
<button type="button" className="btn-link" onClick={onOpenProfile}>
|
||||||
|
{t('logbook_vessel.manage_in_profile')}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{error && <div className="auth-error mb-4">{error}</div>}
|
||||||
|
|
||||||
|
<div className="input-group mb-4">
|
||||||
|
<label>{t('logbook_vessel.active_vessel')}</label>
|
||||||
|
{pool.length === 0 ? (
|
||||||
|
<p className="help-text">{t('logbook_vessel.no_vessels_in_pool')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="crew-selection-list">
|
||||||
|
{pool.map((v) => (
|
||||||
|
<label key={v.payloadId} className="crew-selection-item">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`vessel-${logbookId}`}
|
||||||
|
checked={activeVesselId === v.payloadId}
|
||||||
|
onChange={() => !readOnly && setActiveVesselId(v.payloadId)}
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
<Ship size={16} aria-hidden="true" />
|
||||||
|
<span>{v.data.name || t('logbook_vessel.unnamed')}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{!readOnly && (
|
||||||
|
<label className="crew-selection-item">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={`vessel-${logbookId}`}
|
||||||
|
checked={activeVesselId === null}
|
||||||
|
onChange={() => setActiveVesselId(null)}
|
||||||
|
/>
|
||||||
|
<span>{t('logbook_vessel.no_vessel')}</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{resolvedVessel && (
|
||||||
|
<div className="member-editor-card glass mb-4 logbook-vessel-summary">
|
||||||
|
<h3 className="mb-2">{resolvedVessel.name}</h3>
|
||||||
|
<dl className="profile-dl">
|
||||||
|
{resolvedVessel.homePort && (
|
||||||
|
<div className="profile-dl-row">
|
||||||
|
<dt>{t('vessel.port')}</dt>
|
||||||
|
<dd>{resolvedVessel.homePort}</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{resolvedVessel.registrationNumber && (
|
||||||
|
<div className="profile-dl-row">
|
||||||
|
<dt>{t('vessel.registration')}</dt>
|
||||||
|
<dd>{resolvedVessel.registrationNumber}</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{resolvedVessel.mmsi && (
|
||||||
|
<div className="profile-dl-row">
|
||||||
|
<dt>{t('vessel.mmsi')}</dt>
|
||||||
|
<dd>{resolvedVessel.mmsi}</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!readOnly && logbookId !== 'demo' && (
|
||||||
|
<div className="form-actions">
|
||||||
|
{saved && (
|
||||||
|
<div className="success-toast">
|
||||||
|
<Check size={16} />
|
||||||
|
<span>{t('logbook_vessel.saved')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button type="button" className="btn primary" onClick={() => void handleSave()} disabled={saving}>
|
||||||
|
<Save size={18} />
|
||||||
|
{t('logbook_vessel.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import React, { useCallback } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
export interface MetricRangeInputProps {
|
||||||
|
id?: string
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
disabled?: boolean
|
||||||
|
min?: number
|
||||||
|
max?: number
|
||||||
|
step?: number
|
||||||
|
discreteValues?: readonly number[]
|
||||||
|
parse: (value: string) => number | null
|
||||||
|
format: (numeric: number) => string
|
||||||
|
defaultNumeric: number
|
||||||
|
/** Shown next to the label (current value). */
|
||||||
|
formatDisplay: (numeric: number, unset: boolean) => string
|
||||||
|
numberMin?: number
|
||||||
|
numberMax?: number
|
||||||
|
numberStep?: number | 'any'
|
||||||
|
numberPlaceholder?: string
|
||||||
|
allowLegacyText?: boolean
|
||||||
|
hideNumberInput?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(n: number, min: number, max: number): number {
|
||||||
|
return Math.min(max, Math.max(min, n))
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MetricRangeInput({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled = false,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
discreteValues,
|
||||||
|
parse,
|
||||||
|
format,
|
||||||
|
defaultNumeric,
|
||||||
|
formatDisplay,
|
||||||
|
numberMin,
|
||||||
|
numberMax,
|
||||||
|
numberStep = 'any',
|
||||||
|
numberPlaceholder,
|
||||||
|
allowLegacyText = false,
|
||||||
|
hideNumberInput = false
|
||||||
|
}: MetricRangeInputProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const unsetLabel = t('logs.weather_slider_unset', { defaultValue: '—' })
|
||||||
|
|
||||||
|
const isLegacyText =
|
||||||
|
allowLegacyText && value.trim() !== '' && parse(value) === null
|
||||||
|
|
||||||
|
const emitNumeric = useCallback(
|
||||||
|
(numeric: number) => {
|
||||||
|
onChange(format(numeric))
|
||||||
|
},
|
||||||
|
[onChange, format]
|
||||||
|
)
|
||||||
|
|
||||||
|
const parsed = parse(value)
|
||||||
|
const unset = parsed === null
|
||||||
|
const sliderNumeric = unset ? defaultNumeric : parsed
|
||||||
|
|
||||||
|
const useDiscrete = discreteValues != null && discreteValues.length > 1
|
||||||
|
|
||||||
|
let sliderMin = 0
|
||||||
|
let sliderMax = 0
|
||||||
|
let sliderValue = 0
|
||||||
|
|
||||||
|
if (useDiscrete) {
|
||||||
|
sliderMin = 0
|
||||||
|
sliderMax = discreteValues.length - 1
|
||||||
|
if (unset) {
|
||||||
|
sliderValue = 0
|
||||||
|
} else {
|
||||||
|
let bestIdx = 0
|
||||||
|
let bestDiff = Math.abs(discreteValues[0] - sliderNumeric)
|
||||||
|
for (let i = 1; i < discreteValues.length; i++) {
|
||||||
|
const diff = Math.abs(discreteValues[i] - sliderNumeric)
|
||||||
|
if (diff < bestDiff) {
|
||||||
|
bestDiff = diff
|
||||||
|
bestIdx = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sliderValue = bestIdx
|
||||||
|
}
|
||||||
|
} else if (min != null && max != null) {
|
||||||
|
sliderMin = min
|
||||||
|
sliderMax = max
|
||||||
|
sliderValue = clamp(sliderNumeric, min, max)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const idx = Number(e.target.value)
|
||||||
|
if (useDiscrete && discreteValues) {
|
||||||
|
emitNumeric(discreteValues[clamp(idx, 0, discreteValues.length - 1)])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (min != null && max != null) {
|
||||||
|
emitNumeric(Number(e.target.value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNumberChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onChange(e.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNumberBlur = () => {
|
||||||
|
const next = parse(value)
|
||||||
|
if (next == null) {
|
||||||
|
if (!value.trim()) onChange('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onChange(format(next))
|
||||||
|
}
|
||||||
|
|
||||||
|
const hintNumeric = useDiscrete && discreteValues
|
||||||
|
? discreteValues[sliderValue]
|
||||||
|
: sliderValue
|
||||||
|
|
||||||
|
const displayLabel = unset ? unsetLabel : formatDisplay(hintNumeric, false)
|
||||||
|
|
||||||
|
if (isLegacyText) {
|
||||||
|
return (
|
||||||
|
<div className="input-group metric-range-input metric-range-input--compact">
|
||||||
|
<div className="metric-range-header">
|
||||||
|
<label htmlFor={id}>{label}</label>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="text"
|
||||||
|
className="input-text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={numberPlaceholder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasSlider = useDiscrete || (min != null && max != null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="input-group metric-range-input metric-range-input--compact">
|
||||||
|
<div className="metric-range-header">
|
||||||
|
<label htmlFor={hideNumberInput ? undefined : id}>{label}</label>
|
||||||
|
{hasSlider && (
|
||||||
|
<span className="metric-range-value" aria-live="polite">
|
||||||
|
{displayLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{hasSlider && (
|
||||||
|
<div className="metric-range-control-row">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className="tank-liter-slider metric-range-slider"
|
||||||
|
min={sliderMin}
|
||||||
|
max={sliderMax}
|
||||||
|
step={1}
|
||||||
|
value={sliderValue}
|
||||||
|
onChange={handleSliderChange}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-valuemin={sliderMin}
|
||||||
|
aria-valuemax={sliderMax}
|
||||||
|
aria-valuenow={sliderValue}
|
||||||
|
aria-label={label}
|
||||||
|
aria-valuetext={displayLabel}
|
||||||
|
/>
|
||||||
|
{!hideNumberInput && (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="number"
|
||||||
|
className="input-text metric-range-number"
|
||||||
|
value={unset ? '' : value.replace(/\s*hPa\s*$/i, '').replace(/°\s*$/, '')}
|
||||||
|
onChange={handleNumberChange}
|
||||||
|
onBlur={handleNumberBlur}
|
||||||
|
disabled={disabled}
|
||||||
|
min={numberMin}
|
||||||
|
max={numberMax}
|
||||||
|
step={numberStep}
|
||||||
|
placeholder={numberPlaceholder}
|
||||||
|
inputMode="decimal"
|
||||||
|
aria-label={label}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,28 @@
|
|||||||
import React, { createContext, useContext, useState, useRef, useCallback, useMemo } from 'react'
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
useRef,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useEffect,
|
||||||
|
useId
|
||||||
|
} from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
export type ConfirmLeaveChoice = 'stay' | 'save' | 'discard'
|
||||||
|
|
||||||
interface DialogContextType {
|
interface DialogContextType {
|
||||||
showAlert: (message: string, title?: string, confirmText?: string) => Promise<void>
|
showAlert: (message: string, title?: string, confirmText?: string) => Promise<void>
|
||||||
showConfirm: (message: string, title?: string, confirmText?: string, cancelText?: string) => Promise<boolean>
|
showConfirm: (message: string, title?: string, confirmText?: string, cancelText?: string) => Promise<boolean>
|
||||||
|
showConfirmLeave: (
|
||||||
|
message: string,
|
||||||
|
title?: string,
|
||||||
|
stayLabel?: string,
|
||||||
|
saveLabel?: string,
|
||||||
|
discardLabel?: string,
|
||||||
|
options?: { showSave?: boolean }
|
||||||
|
) => Promise<ConfirmLeaveChoice>
|
||||||
}
|
}
|
||||||
|
|
||||||
const DialogContext = createContext<DialogContextType | undefined>(undefined)
|
const DialogContext = createContext<DialogContextType | undefined>(undefined)
|
||||||
@@ -16,26 +36,36 @@ export function useDialog() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DialogProvider({ children }: { children: React.ReactNode }) {
|
export function DialogProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const titleId = useId()
|
||||||
|
const messageId = useId()
|
||||||
|
const confirmRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [message, setMessage] = useState('')
|
const [message, setMessage] = useState('')
|
||||||
const [type, setType] = useState<'alert' | 'confirm'>('alert')
|
const [type, setType] = useState<'alert' | 'confirm' | 'confirm-leave'>('alert')
|
||||||
const [confirmLabel, setConfirmLabel] = useState('OK')
|
const [confirmLabel, setConfirmLabel] = useState('OK')
|
||||||
const [cancelLabel, setCancelLabel] = useState('Cancel')
|
const [cancelLabel, setCancelLabel] = useState('Cancel')
|
||||||
|
const [saveLabel, setSaveLabel] = useState('')
|
||||||
|
const [discardLabel, setDiscardLabel] = useState('')
|
||||||
|
const [showSaveOption, setShowSaveOption] = useState(false)
|
||||||
|
|
||||||
const resolveRef = useRef<((val: any) => void) | null>(null)
|
const alertResolveRef = useRef<(() => void) | null>(null)
|
||||||
|
const confirmResolveRef = useRef<((val: boolean) => void) | null>(null)
|
||||||
|
const confirmLeaveResolveRef = useRef<((val: ConfirmLeaveChoice) => void) | null>(null)
|
||||||
|
|
||||||
const showAlert = useCallback((msg: string, headerTitle?: string, btnText?: string): Promise<void> => {
|
const showAlert = useCallback((msg: string, headerTitle?: string, btnText?: string): Promise<void> => {
|
||||||
setMessage(msg)
|
setMessage(msg)
|
||||||
setTitle(headerTitle || '')
|
setTitle(headerTitle || '')
|
||||||
setType('alert')
|
setType('alert')
|
||||||
setConfirmLabel(btnText || 'OK')
|
setConfirmLabel(btnText || t('dialog.ok'))
|
||||||
setIsOpen(true)
|
setIsOpen(true)
|
||||||
|
|
||||||
return new Promise<void>((resolve) => {
|
return new Promise<void>((resolve) => {
|
||||||
resolveRef.current = resolve
|
alertResolveRef.current = resolve
|
||||||
})
|
})
|
||||||
}, [])
|
}, [t])
|
||||||
|
|
||||||
const showConfirm = useCallback((
|
const showConfirm = useCallback((
|
||||||
msg: string,
|
msg: string,
|
||||||
@@ -46,53 +76,164 @@ export function DialogProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setMessage(msg)
|
setMessage(msg)
|
||||||
setTitle(headerTitle || '')
|
setTitle(headerTitle || '')
|
||||||
setType('confirm')
|
setType('confirm')
|
||||||
setConfirmLabel(btnConfirm || 'Yes')
|
setConfirmLabel(btnConfirm || t('dialog.yes'))
|
||||||
setCancelLabel(btnCancel || 'No')
|
setCancelLabel(btnCancel || t('dialog.no'))
|
||||||
setIsOpen(true)
|
setIsOpen(true)
|
||||||
|
|
||||||
return new Promise<boolean>((resolve) => {
|
return new Promise<boolean>((resolve) => {
|
||||||
resolveRef.current = resolve
|
confirmResolveRef.current = resolve
|
||||||
})
|
})
|
||||||
|
}, [t])
|
||||||
|
|
||||||
|
const showConfirmLeave = useCallback((
|
||||||
|
msg: string,
|
||||||
|
headerTitle?: string,
|
||||||
|
btnStay?: string,
|
||||||
|
btnSave?: string,
|
||||||
|
btnDiscard?: string,
|
||||||
|
options?: { showSave?: boolean }
|
||||||
|
): Promise<ConfirmLeaveChoice> => {
|
||||||
|
setMessage(msg)
|
||||||
|
setTitle(headerTitle || '')
|
||||||
|
setType('confirm-leave')
|
||||||
|
setCancelLabel(btnStay || t('common.unsaved_changes_stay'))
|
||||||
|
setSaveLabel(btnSave || t('common.unsaved_changes_save_leave'))
|
||||||
|
setDiscardLabel(btnDiscard || t('common.unsaved_changes_discard'))
|
||||||
|
setShowSaveOption(options?.showSave !== false)
|
||||||
|
setIsOpen(true)
|
||||||
|
|
||||||
|
return new Promise<ConfirmLeaveChoice>((resolve) => {
|
||||||
|
confirmLeaveResolveRef.current = resolve
|
||||||
|
})
|
||||||
|
}, [t])
|
||||||
|
|
||||||
|
const closeConfirmLeave = useCallback((choice: ConfirmLeaveChoice) => {
|
||||||
|
setIsOpen(false)
|
||||||
|
if (confirmLeaveResolveRef.current) {
|
||||||
|
confirmLeaveResolveRef.current(choice)
|
||||||
|
confirmLeaveResolveRef.current = null
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleConfirm = useCallback(() => {
|
const handleConfirm = useCallback(() => {
|
||||||
setIsOpen(false)
|
setIsOpen(false)
|
||||||
if (resolveRef.current) {
|
if (type === 'confirm' && confirmResolveRef.current) {
|
||||||
resolveRef.current(type === 'confirm' ? true : undefined)
|
confirmResolveRef.current(true)
|
||||||
resolveRef.current = null
|
confirmResolveRef.current = null
|
||||||
|
} else if (alertResolveRef.current) {
|
||||||
|
alertResolveRef.current()
|
||||||
|
alertResolveRef.current = null
|
||||||
}
|
}
|
||||||
}, [type])
|
}, [type])
|
||||||
|
|
||||||
const handleCancel = useCallback(() => {
|
const handleCancel = useCallback(() => {
|
||||||
setIsOpen(false)
|
if (type === 'confirm-leave') {
|
||||||
if (resolveRef.current) {
|
closeConfirmLeave('stay')
|
||||||
resolveRef.current(false)
|
return
|
||||||
resolveRef.current = null
|
|
||||||
}
|
}
|
||||||
}, [])
|
setIsOpen(false)
|
||||||
|
if (confirmResolveRef.current) {
|
||||||
|
confirmResolveRef.current(false)
|
||||||
|
confirmResolveRef.current = null
|
||||||
|
}
|
||||||
|
}, [type, closeConfirmLeave])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return
|
||||||
|
confirmRef.current?.focus()
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (type === 'confirm' || type === 'confirm-leave') handleCancel()
|
||||||
|
else handleConfirm()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown)
|
||||||
|
}, [isOpen, type, handleCancel, handleConfirm])
|
||||||
|
|
||||||
const contextValue = useMemo(
|
const contextValue = useMemo(
|
||||||
() => ({ showAlert, showConfirm }),
|
() => ({ showAlert, showConfirm, showConfirmLeave }),
|
||||||
[showAlert, showConfirm]
|
[showAlert, showConfirm, showConfirmLeave]
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogContext.Provider value={contextValue}>
|
<DialogContext.Provider value={contextValue}>
|
||||||
{children}
|
{children}
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="custom-dialog-overlay" onClick={type === 'alert' ? handleConfirm : undefined}>
|
<div
|
||||||
<div className="custom-dialog-card glass scale-in" onClick={(e) => e.stopPropagation()}>
|
className="custom-dialog-overlay"
|
||||||
{title && <h3 className="custom-dialog-title">{title}</h3>}
|
onClick={type === 'confirm' || type === 'confirm-leave' ? handleCancel : handleConfirm}
|
||||||
<p className="custom-dialog-message">{message}</p>
|
>
|
||||||
|
<div
|
||||||
|
className="custom-dialog-card glass scale-in"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={title ? titleId : undefined}
|
||||||
|
aria-describedby={messageId}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{title && (
|
||||||
|
<h3 id={titleId} className="custom-dialog-title">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
|
<p id={messageId} className="custom-dialog-message">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
<div className="custom-dialog-actions">
|
<div className="custom-dialog-actions">
|
||||||
{type === 'confirm' && (
|
{type === 'confirm-leave' ? (
|
||||||
<button type="button" className="btn secondary" onClick={handleCancel} style={{ width: 'auto', padding: '8px 20px', margin: 0 }}>
|
<>
|
||||||
{cancelLabel}
|
<button
|
||||||
</button>
|
ref={confirmRef}
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={handleCancel}
|
||||||
|
style={{ width: 'auto', padding: '8px 20px', margin: 0 }}
|
||||||
|
>
|
||||||
|
{cancelLabel}
|
||||||
|
</button>
|
||||||
|
{showSaveOption && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn primary"
|
||||||
|
onClick={() => closeConfirmLeave('save')}
|
||||||
|
style={{ width: 'auto', padding: '8px 20px', margin: 0 }}
|
||||||
|
>
|
||||||
|
{saveLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn danger"
|
||||||
|
onClick={() => closeConfirmLeave('discard')}
|
||||||
|
style={{ width: 'auto', padding: '8px 20px', margin: 0 }}
|
||||||
|
>
|
||||||
|
{discardLabel}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{type === 'confirm' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={handleCancel}
|
||||||
|
style={{ width: 'auto', padding: '8px 20px', margin: 0 }}
|
||||||
|
>
|
||||||
|
{cancelLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
ref={confirmRef}
|
||||||
|
type="button"
|
||||||
|
className="btn primary"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
style={{ width: 'auto', minWidth: '80px', padding: '8px 20px', margin: 0 }}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<button type="button" className="btn primary" onClick={handleConfirm} style={{ width: 'auto', minWidth: '80px', padding: '8px 20px', margin: 0 }}>
|
|
||||||
{confirmLabel}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,333 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { FileText, X } from 'lucide-react'
|
||||||
|
import type { LogEventPayload } from '../utils/logEntryPayload.js'
|
||||||
|
import { sortLogEventsByTime } from '../utils/logEntryPayload.js'
|
||||||
|
import { parseNmeaFile, nmeaPointsToWaypoints } from '../services/nmea/nmeaParse.js'
|
||||||
|
import { filterPointsForDate } from '../services/nmea/nmeaTimeSeries.js'
|
||||||
|
import { generateNmeaJournalCandidates } from '../services/nmea/nmeaJournalGenerator.js'
|
||||||
|
import type { NmeaImportMode, NmeaParseResult } from '../services/nmea/nmeaTypes.js'
|
||||||
|
import { saveNmeaArchive, recordNmeaFileImport, type NmeaArchiveRecord } from '../services/nmeaArchive.js'
|
||||||
|
import { nmeaFileCrc32 } from '../utils/crc32.js'
|
||||||
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
|
import type { TrackWaypoint } from '../services/trackUpload.js'
|
||||||
|
|
||||||
|
interface NmeaImportWizardProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
logbookId: string
|
||||||
|
entryId: string
|
||||||
|
entryDate: string
|
||||||
|
nmeaArchive: NmeaArchiveRecord | null
|
||||||
|
onImport: (events: LogEventPayload[], waypoints?: TrackWaypoint[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type WizardStep = 'config' | 'preview' | 'archive'
|
||||||
|
|
||||||
|
export default function NmeaImportWizard({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
logbookId,
|
||||||
|
entryId,
|
||||||
|
entryDate,
|
||||||
|
nmeaArchive,
|
||||||
|
onImport
|
||||||
|
}: NmeaImportWizardProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [step, setStep] = useState<WizardStep>('config')
|
||||||
|
const [parseResult, setParseResult] = useState<NmeaParseResult | null>(null)
|
||||||
|
const [mode, setMode] = useState<NmeaImportMode>('both')
|
||||||
|
const [intervalMinutes, setIntervalMinutes] = useState(60)
|
||||||
|
const [importTrack, setImportTrack] = useState(true)
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [pendingRaw, setPendingRaw] = useState<{ filename: string; text: string } | null>(null)
|
||||||
|
const [duplicateFile, setDuplicateFile] = useState(false)
|
||||||
|
|
||||||
|
const filteredPoints = useMemo(() => {
|
||||||
|
if (!parseResult) return []
|
||||||
|
return filterPointsForDate(parseResult.points, entryDate)
|
||||||
|
}, [parseResult, entryDate])
|
||||||
|
|
||||||
|
const candidates = useMemo(() => {
|
||||||
|
if (!parseResult || filteredPoints.length === 0) return []
|
||||||
|
return generateNmeaJournalCandidates({
|
||||||
|
points: filteredPoints,
|
||||||
|
mode,
|
||||||
|
intervalMinutes,
|
||||||
|
t
|
||||||
|
}).candidates
|
||||||
|
}, [parseResult, filteredPoints, mode, intervalMinutes, t])
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setStep('config')
|
||||||
|
setParseResult(null)
|
||||||
|
setMode('both')
|
||||||
|
setIntervalMinutes(60)
|
||||||
|
setImportTrack(true)
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
setError(null)
|
||||||
|
setDuplicateFile(false)
|
||||||
|
setPendingRaw(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
reset()
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFile = (file: File) => {
|
||||||
|
setError(null)
|
||||||
|
setDuplicateFile(false)
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
try {
|
||||||
|
const text = String(reader.result ?? '')
|
||||||
|
const crc32 = nmeaFileCrc32(text)
|
||||||
|
const alreadyImported = nmeaArchive?.importedFiles.some((item) => item.crc32 === crc32) ?? false
|
||||||
|
setDuplicateFile(alreadyImported)
|
||||||
|
const result = parseNmeaFile(text, file.name)
|
||||||
|
if (result.points.length === 0) {
|
||||||
|
setError(t('logs.nmea_error_no_samples'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setParseResult(result)
|
||||||
|
setPendingRaw({ filename: file.name, text })
|
||||||
|
const generated = generateNmeaJournalCandidates({
|
||||||
|
points: filterPointsForDate(result.points, entryDate),
|
||||||
|
mode,
|
||||||
|
intervalMinutes,
|
||||||
|
t
|
||||||
|
}).candidates
|
||||||
|
setSelectedIds(new Set(generated.map((c) => c.id)))
|
||||||
|
trackPlausibleEvent(PlausibleEvents.NMEA_UPLOADED, {
|
||||||
|
duplicate: alreadyImported,
|
||||||
|
lines: result.stats.parsedLines,
|
||||||
|
candidates: generated.length,
|
||||||
|
has_position: !result.warnings.includes('no_position')
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : t('logs.nmea_error_parse'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.onerror = () => setError(t('logs.nmea_error_read'))
|
||||||
|
reader.readAsText(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleAll = (checked: boolean) => {
|
||||||
|
setSelectedIds(checked ? new Set(candidates.map((c) => c.id)) : new Set())
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleOne = (id: string) => {
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(id)) next.delete(id)
|
||||||
|
else next.add(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const goPreview = () => {
|
||||||
|
if (!parseResult) {
|
||||||
|
setError(t('logs.nmea_error_no_file'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const generated = generateNmeaJournalCandidates({
|
||||||
|
points: filteredPoints,
|
||||||
|
mode,
|
||||||
|
intervalMinutes,
|
||||||
|
t
|
||||||
|
}).candidates
|
||||||
|
setSelectedIds(new Set(generated.map((c) => c.id)))
|
||||||
|
setStep('preview')
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyImport = async () => {
|
||||||
|
const picked = candidates.filter((c) => selectedIds.has(c.id)).map((c) => c.event)
|
||||||
|
if (picked.length === 0) {
|
||||||
|
setError(t('logs.nmea_error_no_selection'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const waypoints = importTrack ? nmeaPointsToWaypoints(filteredPoints) : undefined
|
||||||
|
onImport(sortLogEventsByTime(picked), waypoints)
|
||||||
|
if (pendingRaw) {
|
||||||
|
try {
|
||||||
|
await recordNmeaFileImport(logbookId, entryId, pendingRaw.filename, pendingRaw.text)
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('NMEA import CRC record failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
trackPlausibleEvent(PlausibleEvents.NMEA_IMPORTED, {
|
||||||
|
mode,
|
||||||
|
events: picked.length,
|
||||||
|
track: importTrack && (waypoints?.length ?? 0) > 0
|
||||||
|
})
|
||||||
|
setStep('archive')
|
||||||
|
}
|
||||||
|
|
||||||
|
const finishArchive = async (archive: boolean) => {
|
||||||
|
try {
|
||||||
|
if (archive && pendingRaw) {
|
||||||
|
await saveNmeaArchive(logbookId, entryId, pendingRaw.filename, pendingRaw.text)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('NMEA archive save failed:', err)
|
||||||
|
}
|
||||||
|
handleClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') handleClose()
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKeyDown)
|
||||||
|
const prevOverflow = document.body.style.overflow
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
|
document.body.style.overflow = prevOverflow
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="disclaimer-modal-overlay" onClick={handleClose}>
|
||||||
|
<div className="disclaimer-modal-panel" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="auth-card glass registration-disclaimer registration-disclaimer--modal feedback-modal">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="registration-disclaimer__close feedback-modal__close"
|
||||||
|
onClick={handleClose}
|
||||||
|
aria-label={t('logs.nmea_cancel')}
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="auth-header">
|
||||||
|
<FileText className="auth-icon accent" size={40} />
|
||||||
|
<h2>{t('logs.nmea_import_title')}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="track-error-msg">{error}</div>}
|
||||||
|
|
||||||
|
{duplicateFile && (
|
||||||
|
<div className="nmea-import-warning" role="status">
|
||||||
|
{t('logs.nmea_warn_duplicate_file')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'config' && (
|
||||||
|
<>
|
||||||
|
<p className="registration-disclaimer__intro">{t('logs.nmea_import_intro')}</p>
|
||||||
|
<label className="feedback-form__field">
|
||||||
|
<span>{t('logs.nmea_file_label')}</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".nmea,.log,.txt"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (file) handleFile(file)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{parseResult && (
|
||||||
|
<div className="nmea-import-summary">
|
||||||
|
<p>{t('logs.nmea_stats', {
|
||||||
|
lines: parseResult.stats.parsedLines,
|
||||||
|
types: parseResult.stats.sentenceTypes.join(', ')
|
||||||
|
})}</p>
|
||||||
|
{parseResult.warnings.includes('no_position') && (
|
||||||
|
<p>{t('logs.nmea_warn_no_position')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<fieldset className="nmea-import-mode">
|
||||||
|
<legend>{t('logs.nmea_mode_label')}</legend>
|
||||||
|
<label><input type="radio" name="nmea-mode" checked={mode === 'interval'} onChange={() => setMode('interval')} /> {t('logs.nmea_mode_interval')}</label>
|
||||||
|
<label><input type="radio" name="nmea-mode" checked={mode === 'change'} onChange={() => setMode('change')} /> {t('logs.nmea_mode_change')}</label>
|
||||||
|
<label><input type="radio" name="nmea-mode" checked={mode === 'both'} onChange={() => setMode('both')} /> {t('logs.nmea_mode_both')}</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{(mode === 'interval' || mode === 'both') && (
|
||||||
|
<label className="feedback-form__field">
|
||||||
|
<span>{t('logs.nmea_interval_label')}</span>
|
||||||
|
<select value={intervalMinutes} onChange={(e) => setIntervalMinutes(Number(e.target.value))}>
|
||||||
|
<option value={30}>30 min</option>
|
||||||
|
<option value={60}>60 min</option>
|
||||||
|
<option value={90}>90 min</option>
|
||||||
|
<option value={120}>120 min</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label className="nmea-import-checkbox">
|
||||||
|
<input type="checkbox" checked={importTrack} onChange={(e) => setImportTrack(e.target.checked)} />
|
||||||
|
{t('logs.nmea_import_track')}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="auth-actions feedback-form__actions">
|
||||||
|
<button type="button" className="btn secondary" onClick={handleClose}>{t('logs.nmea_cancel')}</button>
|
||||||
|
<button type="button" className="btn primary" onClick={goPreview} disabled={!parseResult}>
|
||||||
|
{t('logs.nmea_preview')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'preview' && (
|
||||||
|
<>
|
||||||
|
<p>{t('logs.nmea_preview_hint', { count: candidates.length })}</p>
|
||||||
|
<div className="nmea-preview-actions">
|
||||||
|
<button type="button" className="btn secondary" onClick={() => toggleAll(true)}>{t('logs.nmea_select_all')}</button>
|
||||||
|
<button type="button" className="btn secondary" onClick={() => toggleAll(false)}>{t('logs.nmea_select_none')}</button>
|
||||||
|
</div>
|
||||||
|
<div className="nmea-preview-list">
|
||||||
|
{candidates.map((c) => (
|
||||||
|
<label key={c.id} className="nmea-preview-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="nmea-preview-row__check"
|
||||||
|
checked={selectedIds.has(c.id)}
|
||||||
|
onChange={() => toggleOne(c.id)}
|
||||||
|
/>
|
||||||
|
<div className="nmea-preview-row__body">
|
||||||
|
<div className="nmea-preview-row__meta">
|
||||||
|
<span className="nmea-preview-time">{c.event.time}</span>
|
||||||
|
<span className="nmea-preview-source">{t(`logs.nmea_source_${c.source}`)}</span>
|
||||||
|
</div>
|
||||||
|
<span className="nmea-preview-remarks">{c.event.remarks || c.event.mgk || '—'}</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="auth-actions feedback-form__actions">
|
||||||
|
<button type="button" className="btn secondary" onClick={() => setStep('config')}>{t('logs.nmea_back')}</button>
|
||||||
|
<button type="button" className="btn primary" onClick={applyImport}>{t('logs.nmea_apply')}</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'archive' && (
|
||||||
|
<>
|
||||||
|
<p>{t('logs.nmea_archive_question')}</p>
|
||||||
|
<div className="auth-actions feedback-form__actions">
|
||||||
|
<button type="button" className="btn secondary" onClick={() => finishArchive(false)}>
|
||||||
|
{t('logs.nmea_archive_discard')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn primary" onClick={() => finishArchive(true)}>
|
||||||
|
{t('logs.nmea_archive_keep')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Fingerprint, Loader2, AlertTriangle } from 'lucide-react'
|
import { Fingerprint, Loader2, AlertTriangle } from 'lucide-react'
|
||||||
import type { PasskeySignature } from '../types/signatures.js'
|
import type { PasskeySignature } from '../types/signatures.js'
|
||||||
|
import { formatAppDateTime } from '../utils/dateTimeFormat.js'
|
||||||
|
|
||||||
interface PasskeySignButtonProps {
|
interface PasskeySignButtonProps {
|
||||||
label: string
|
label: string
|
||||||
@@ -42,9 +43,7 @@ export default function PasskeySignButton({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const formattedDate = signature
|
const formattedDate = signature ? formatAppDateTime(signature.signedAt, i18n.language) : ''
|
||||||
? new Date(signature.signedAt).toLocaleString(i18n.language === 'de' ? 'de-DE' : 'en-GB')
|
|
||||||
: ''
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="passkey-sign-block">
|
<div className="passkey-sign-block">
|
||||||
|
|||||||
@@ -0,0 +1,313 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Users, User, Plus, Trash2, Edit2, X, Camera, Save } from 'lucide-react'
|
||||||
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
|
import { resizeImageFile } from '../utils/resizeImageFile.js'
|
||||||
|
import type { PersonData, PersonRole } from '../types/person.js'
|
||||||
|
import { MAX_POOL_CREW_MEMBERS } from '../types/person.js'
|
||||||
|
import {
|
||||||
|
loadPersonPool,
|
||||||
|
savePerson,
|
||||||
|
deletePerson,
|
||||||
|
filterSkippers,
|
||||||
|
filterCrew,
|
||||||
|
type DecryptedPerson
|
||||||
|
} from '../services/personPool.js'
|
||||||
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
|
|
||||||
|
const emptyPerson = (role: PersonRole): PersonData => ({
|
||||||
|
name: '',
|
||||||
|
address: '',
|
||||||
|
birthDate: '',
|
||||||
|
phone: '',
|
||||||
|
nationality: '',
|
||||||
|
passportNumber: '',
|
||||||
|
bloodType: '',
|
||||||
|
allergies: '',
|
||||||
|
diseases: '',
|
||||||
|
role,
|
||||||
|
photo: null
|
||||||
|
})
|
||||||
|
|
||||||
|
export default function PersonPoolForm() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { showConfirm } = useDialog()
|
||||||
|
const [people, setPeople] = useState<DecryptedPerson[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [formRole, setFormRole] = useState<PersonRole>('crew')
|
||||||
|
const [form, setForm] = useState<PersonData>(emptyPerson('crew'))
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [photoError, setPhotoError] = useState<string | null>(null)
|
||||||
|
const fileRef = React.useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
setPeople(await loadPersonPool())
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void reload()
|
||||||
|
}, [reload])
|
||||||
|
|
||||||
|
const openAdd = (role: PersonRole) => {
|
||||||
|
setEditingId(null)
|
||||||
|
setFormRole(role)
|
||||||
|
setForm(emptyPerson(role))
|
||||||
|
setPhotoError(null)
|
||||||
|
setShowForm(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEdit = (person: DecryptedPerson) => {
|
||||||
|
setEditingId(person.payloadId)
|
||||||
|
setFormRole(person.data.role)
|
||||||
|
setForm({ ...person.data })
|
||||||
|
setPhotoError(null)
|
||||||
|
setShowForm(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!form.name.trim()) return
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const id = editingId ?? window.crypto.randomUUID()
|
||||||
|
await savePerson(id, { ...form, role: formRole }, !editingId)
|
||||||
|
setShowForm(false)
|
||||||
|
trackPlausibleEvent(PlausibleEvents.CREW_SAVED, { role: formRole, context: 'person_pool' })
|
||||||
|
await reload()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof Error && err.message === 'MAX_CREW') {
|
||||||
|
setError(t('crew.max_crew'))
|
||||||
|
} else {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to save')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (
|
||||||
|
!(await showConfirm(
|
||||||
|
t('person_pool.delete_confirm'),
|
||||||
|
t('person_pool.title'),
|
||||||
|
t('logs.confirm_yes'),
|
||||||
|
t('logs.confirm_no')
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await deletePerson(id)
|
||||||
|
await reload()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to delete')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const skippers = filterSkippers(people)
|
||||||
|
const crewList = filterCrew(people)
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="tab-placeholder">
|
||||||
|
<Users className="header-logo spin" size={48} />
|
||||||
|
<p>{t('person_pool.loading')}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderCard = (person: DecryptedPerson) => (
|
||||||
|
<div key={person.payloadId} className="crew-member-card glass">
|
||||||
|
<div className="crew-card-header">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
{person.data.photo ? (
|
||||||
|
<img src={person.data.photo} alt="" className="crew-card-avatar" />
|
||||||
|
) : (
|
||||||
|
<div className="crew-card-avatar-placeholder">
|
||||||
|
<User size={18} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<h4>{person.data.name}</h4>
|
||||||
|
</div>
|
||||||
|
<div className="card-actions">
|
||||||
|
<button type="button" className="btn-icon" onClick={() => openEdit(person)} title="Edit">
|
||||||
|
<Edit2 size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-icon danger"
|
||||||
|
onClick={() => void handleDelete(person.payloadId)}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{person.data.phone && (
|
||||||
|
<p className="help-text">
|
||||||
|
<strong>{t('crew.phone')}:</strong> {person.data.phone}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="form-card" data-tour="profile-crew-pool">
|
||||||
|
<div className="form-header">
|
||||||
|
<Users size={24} className="form-icon" />
|
||||||
|
<h2>{t('person_pool.title')}</h2>
|
||||||
|
</div>
|
||||||
|
<p className="help-text mb-4">{t('person_pool.subtitle')}</p>
|
||||||
|
{error && <div className="auth-error mb-4">{error}</div>}
|
||||||
|
|
||||||
|
<div className="section-title-bar mb-4">
|
||||||
|
<h3>{t('person_pool.skippers_section')}</h3>
|
||||||
|
{!showForm && (
|
||||||
|
<button type="button" className="btn primary" style={{ width: 'auto', padding: '8px 16px' }} onClick={() => openAdd('skipper')}>
|
||||||
|
<Plus size={16} />
|
||||||
|
{t('person_pool.add_skipper')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{skippers.length === 0 ? (
|
||||||
|
<p className="help-text mb-4">{t('person_pool.no_skippers')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="crew-grid mb-6">{skippers.map(renderCard)}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="section-title-bar mb-4">
|
||||||
|
<h3>{t('person_pool.crew_section')}</h3>
|
||||||
|
{!showForm && crewList.length < MAX_POOL_CREW_MEMBERS && (
|
||||||
|
<button type="button" className="btn primary" style={{ width: 'auto', padding: '8px 16px' }} onClick={() => openAdd('crew')}>
|
||||||
|
<Plus size={16} />
|
||||||
|
{t('person_pool.add_crew')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{crewList.length === 0 ? (
|
||||||
|
<p className="help-text">{t('person_pool.no_crew')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="crew-grid">{crewList.map(renderCard)}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<form onSubmit={(e) => void handleSave(e)} className="member-editor-card glass mt-6">
|
||||||
|
<div className="editor-header mb-4">
|
||||||
|
<h3>
|
||||||
|
{editingId
|
||||||
|
? formRole === 'skipper'
|
||||||
|
? t('person_pool.edit_skipper')
|
||||||
|
: t('crew.edit_crew')
|
||||||
|
: formRole === 'skipper'
|
||||||
|
? t('person_pool.add_skipper')
|
||||||
|
: t('crew.add_crew')}
|
||||||
|
</h3>
|
||||||
|
<button type="button" className="btn-icon" onClick={() => setShowForm(false)}>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="form-grid">
|
||||||
|
<div className="vessel-photo-wrapper">
|
||||||
|
<div className="vessel-photo-preview" onClick={() => fileRef.current?.click()}>
|
||||||
|
{form.photo ? (
|
||||||
|
<img src={form.photo} alt="" className="vessel-photo" />
|
||||||
|
) : (
|
||||||
|
<div className="vessel-photo-placeholder">
|
||||||
|
<User size={48} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="vessel-photo-overlay">
|
||||||
|
<Camera size={24} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
void resizeImageFile(file)
|
||||||
|
.then((photo) => setForm((f) => ({ ...f, photo })))
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
setPhotoError(err instanceof Error ? err.message : 'Image error')
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{photoError && <div className="auth-error mt-2">{photoError}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="input-group">
|
||||||
|
<label>{t('crew.name')} *</label>
|
||||||
|
<input
|
||||||
|
className="input-text"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="input-group">
|
||||||
|
<label>{t('crew.address')}</label>
|
||||||
|
<input
|
||||||
|
className="input-text"
|
||||||
|
value={form.address}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, address: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="input-group">
|
||||||
|
<label>{t('crew.birthdate')}</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="input-text"
|
||||||
|
value={form.birthDate}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, birthDate: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="input-group">
|
||||||
|
<label>{t('crew.phone')}</label>
|
||||||
|
<input
|
||||||
|
className="input-text"
|
||||||
|
value={form.phone}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, phone: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="input-group">
|
||||||
|
<label>{t('crew.nationality')}</label>
|
||||||
|
<input
|
||||||
|
className="input-text"
|
||||||
|
value={form.nationality}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, nationality: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="input-group">
|
||||||
|
<label>{t('crew.passport')}</label>
|
||||||
|
<input
|
||||||
|
className="input-text"
|
||||||
|
value={form.passportNumber}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, passportNumber: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="editor-actions mt-4">
|
||||||
|
<button type="submit" className="btn primary" disabled={saving || !form.name.trim()}>
|
||||||
|
<Save size={18} />
|
||||||
|
{t('crew.save_member')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react'
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { db } from '../services/db.js'
|
import { db } from '../services/db.js'
|
||||||
import { getActiveMasterKey } from '../services/auth.js'
|
import { getActiveMasterKey } from '../services/auth.js'
|
||||||
import { getLogbookKey } from '../services/logbookKeys.js'
|
import { getLogbookKey } from '../services/logbookKeys.js'
|
||||||
import { encryptJson, decryptJson } from '../services/crypto.js'
|
import { decryptJson } from '../services/crypto.js'
|
||||||
import { syncLogbook } from '../services/sync.js'
|
import { saveEntryPhoto, deleteEntryPhoto } from '../services/photoAttachments.js'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { fileToCompressedJpegDataUrl } from '../utils/imageCompress.js'
|
||||||
import { useLiveQuery } from 'dexie-react-hooks'
|
import { useLiveQuery } from 'dexie-react-hooks'
|
||||||
import { useDialog } from './ModalDialog.tsx'
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
import { Camera, Trash2 } from 'lucide-react'
|
import { Camera, Image, Trash2, X, ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
|
import { probeCameraAvailability } from '../utils/cameraAvailability.js'
|
||||||
|
|
||||||
interface PhotoCaptureProps {
|
interface PhotoCaptureProps {
|
||||||
entryId: string
|
entryId: string
|
||||||
@@ -27,12 +29,43 @@ interface DecryptedPhoto {
|
|||||||
export default function PhotoCapture({ entryId, logbookId, readOnly = false, preloadedPhotos }: PhotoCaptureProps) {
|
export default function PhotoCapture({ entryId, logbookId, readOnly = false, preloadedPhotos }: PhotoCaptureProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { showConfirm } = useDialog()
|
const { showConfirm } = useDialog()
|
||||||
|
const [collapsed, setCollapsed] = useState(true)
|
||||||
const [caption, setCaption] = useState('')
|
const [caption, setCaption] = useState('')
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [decryptedPhotos, setDecryptedPhotos] = useState<DecryptedPhoto[]>([])
|
const [decryptedPhotos, setDecryptedPhotos] = useState<DecryptedPhoto[]>([])
|
||||||
|
const [hasCamera, setHasCamera] = useState(false)
|
||||||
|
const [maximizedPhoto, setMaximizedPhoto] = useState<DecryptedPhoto | null>(null)
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const cameraInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!maximizedPhoto) return
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setMaximizedPhoto(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}
|
||||||
|
}, [maximizedPhoto])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
probeCameraAvailability().then((avail) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setHasCamera(avail === 'available')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Reactively query local photos database
|
// Reactively query local photos database
|
||||||
const localPhotos = useLiveQuery(
|
const localPhotos = useLiveQuery(
|
||||||
@@ -90,201 +123,230 @@ export default function PhotoCapture({ entryId, logbookId, readOnly = false, pre
|
|||||||
setUploading(true)
|
setUploading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
const reader = new FileReader()
|
try {
|
||||||
reader.onload = (event) => {
|
const compressedBase64 = await fileToCompressedJpegDataUrl(file)
|
||||||
const img = new Image()
|
await saveEntryPhoto({
|
||||||
img.onload = async () => {
|
logbookId,
|
||||||
try {
|
entryId,
|
||||||
const canvas = document.createElement('canvas')
|
imageDataUrl: compressedBase64,
|
||||||
const ctx = canvas.getContext('2d')
|
caption: caption.trim(),
|
||||||
if (!ctx) throw new Error('Could not get canvas context')
|
analyticsContext: 'logbook'
|
||||||
|
})
|
||||||
let width = img.width
|
setCaption('')
|
||||||
let height = img.height
|
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||||
const MAX_WIDTH = 1280
|
} catch (err: unknown) {
|
||||||
const MAX_HEIGHT = 720
|
console.error('Failed to process image:', err)
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to process image')
|
||||||
// Calculate resizing conserving aspect ratio
|
} finally {
|
||||||
if (width > MAX_WIDTH || height > MAX_HEIGHT) {
|
setUploading(false)
|
||||||
const ratio = Math.min(MAX_WIDTH / width, MAX_HEIGHT / height)
|
|
||||||
width = Math.round(width * ratio)
|
|
||||||
height = Math.round(height * ratio)
|
|
||||||
}
|
|
||||||
|
|
||||||
canvas.width = width
|
|
||||||
canvas.height = height
|
|
||||||
ctx.drawImage(img, 0, 0, width, height)
|
|
||||||
|
|
||||||
// Compress to JPEG, 70% quality
|
|
||||||
const compressedBase64 = canvas.toDataURL('image/jpeg', 0.7)
|
|
||||||
|
|
||||||
// Encrypt
|
|
||||||
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
|
||||||
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
|
|
||||||
|
|
||||||
const photoId = window.crypto.randomUUID()
|
|
||||||
const photoPayload = {
|
|
||||||
image: compressedBase64,
|
|
||||||
caption: caption.trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
const encrypted = await encryptJson(photoPayload, masterKey)
|
|
||||||
const now = new Date().toISOString()
|
|
||||||
|
|
||||||
// Store locally
|
|
||||||
await db.photos.put({
|
|
||||||
payloadId: photoId,
|
|
||||||
entryId,
|
|
||||||
logbookId,
|
|
||||||
encryptedData: encrypted.ciphertext,
|
|
||||||
iv: encrypted.iv,
|
|
||||||
tag: encrypted.tag,
|
|
||||||
caption: '', // stored encrypted inside payload
|
|
||||||
updatedAt: now
|
|
||||||
})
|
|
||||||
|
|
||||||
// Queue for background sync
|
|
||||||
await db.syncQueue.put({
|
|
||||||
action: 'create',
|
|
||||||
type: 'photo',
|
|
||||||
payloadId: photoId,
|
|
||||||
logbookId,
|
|
||||||
data: JSON.stringify({
|
|
||||||
encryptedData: encrypted.ciphertext,
|
|
||||||
iv: encrypted.iv,
|
|
||||||
tag: encrypted.tag,
|
|
||||||
entryId
|
|
||||||
}),
|
|
||||||
updatedAt: now
|
|
||||||
})
|
|
||||||
|
|
||||||
setCaption('')
|
|
||||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
|
||||||
trackPlausibleEvent(PlausibleEvents.PHOTO_UPLOADED, { context: 'logbook' })
|
|
||||||
|
|
||||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Failed to process image:', err)
|
|
||||||
setError(err.message || 'Failed to process image')
|
|
||||||
} finally {
|
|
||||||
setUploading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
img.src = event.target?.result as string
|
|
||||||
}
|
}
|
||||||
reader.readAsDataURL(file)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async (photoId: string) => {
|
const handleDelete = async (photoId: string) => {
|
||||||
if (await showConfirm(t('logs.photo_delete_confirm'), t('logs.photos_title'), t('logs.confirm_yes'), t('logs.confirm_no'))) {
|
if (await showConfirm(t('logs.photo_delete_confirm'), t('logs.photos_title'), t('logs.confirm_yes'), t('logs.confirm_no'))) {
|
||||||
try {
|
try {
|
||||||
const now = new Date().toISOString()
|
await deleteEntryPhoto(logbookId, photoId)
|
||||||
|
} catch (err: unknown) {
|
||||||
await db.photos.delete(photoId)
|
|
||||||
|
|
||||||
await db.syncQueue.put({
|
|
||||||
action: 'delete',
|
|
||||||
type: 'photo',
|
|
||||||
payloadId: photoId,
|
|
||||||
logbookId,
|
|
||||||
data: '',
|
|
||||||
updatedAt: now
|
|
||||||
})
|
|
||||||
|
|
||||||
syncLogbook(logbookId).catch((err) => console.warn('Background sync failed:', err))
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Failed to delete photo:', err)
|
console.error('Failed to delete photo:', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const triggerSelect = () => {
|
const triggerGallerySelect = () => {
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.click()
|
fileInputRef.current.click()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const triggerCameraSelect = () => {
|
||||||
|
if (cameraInputRef.current) {
|
||||||
|
cameraInputRef.current.click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="form-card mt-6">
|
<div className="form-card mt-6">
|
||||||
<div className="form-header mb-4">
|
<div
|
||||||
<Camera size={20} className="form-icon" />
|
className="form-header accordion-header"
|
||||||
<h3>{t('logs.photos_title')}</h3>
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
setCollapsed(!collapsed)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
aria-expanded={!collapsed}
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<div className="accordion-header-title">
|
||||||
|
<Camera size={20} className="form-icon" />
|
||||||
|
<h3>{t('logs.photos_title')}</h3>
|
||||||
|
</div>
|
||||||
|
{collapsed ? (
|
||||||
|
<ChevronDown size={20} className="accordion-chevron" />
|
||||||
|
) : (
|
||||||
|
<ChevronUp size={20} className="accordion-chevron" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <div className="auth-error mb-4">{error}</div>}
|
{!collapsed && (
|
||||||
|
<div style={{ marginTop: '16px' }}>
|
||||||
|
{error && <div className="auth-error mb-4">{error}</div>}
|
||||||
|
|
||||||
{/* Upload area */}
|
{/* Upload area */}
|
||||||
{/* Upload Form */}
|
{/* Upload Form */}
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<div className="member-editor-card glass mb-6" style={{ padding: '16px' }}>
|
<div className="member-editor-card glass mb-6" style={{ padding: '16px' }}>
|
||||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '12px', alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
||||||
<div className="input-group" style={{ flex: '1', minWidth: '200px', margin: 0 }}>
|
<div className="input-group" style={{ flex: '1', minWidth: '200px', margin: 0 }}>
|
||||||
<label>{t('logs.photo_caption_label')}</label>
|
<label>{t('logs.photo_caption_label')}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={t('logs.photo_caption_placeholder')}
|
placeholder={t('logs.photo_caption_placeholder')}
|
||||||
className="input-text"
|
className="input-text"
|
||||||
value={caption}
|
value={caption}
|
||||||
onChange={(e) => setCaption(e.target.value)}
|
onChange={(e) => setCaption(e.target.value)}
|
||||||
disabled={uploading}
|
disabled={uploading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
<input
|
||||||
type="button"
|
type="file"
|
||||||
className="btn primary"
|
accept="image/*"
|
||||||
onClick={triggerSelect}
|
capture="environment"
|
||||||
disabled={uploading}
|
ref={cameraInputRef}
|
||||||
style={{ width: 'auto', padding: '12px 24px', display: 'flex', gap: '8px', alignItems: 'center' }}
|
onChange={handleFileChange}
|
||||||
>
|
style={{ display: 'none' }}
|
||||||
{uploading ? (
|
/>
|
||||||
<span className="spin">⏳</span>
|
|
||||||
) : (
|
|
||||||
<Camera size={16} />
|
|
||||||
)}
|
|
||||||
{uploading ? t('logs.photo_processing') : t('logs.photo_btn')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Photo Grid */}
|
{hasCamera ? (
|
||||||
{decryptedPhotos.length === 0 ? (
|
<>
|
||||||
<div className="dashboard-status-msg">{t('logs.no_photos')}</div>
|
<button
|
||||||
) : (
|
type="button"
|
||||||
<div className="photo-attachments-grid">
|
className="btn primary"
|
||||||
{decryptedPhotos.map((photo) => (
|
onClick={triggerCameraSelect}
|
||||||
<div key={photo.payloadId} className="photo-card glass">
|
disabled={uploading}
|
||||||
<div className="photo-container">
|
style={{ width: 'auto', padding: '12px 20px', display: 'flex', gap: '8px', alignItems: 'center' }}
|
||||||
<img src={photo.image} alt={photo.caption || 'Attachment'} loading="lazy" />
|
>
|
||||||
{!readOnly && (
|
{uploading ? (
|
||||||
|
<span className="spin">⏳</span>
|
||||||
|
) : (
|
||||||
|
<Camera size={16} />
|
||||||
|
)}
|
||||||
|
{uploading ? t('logs.photo_processing') : t('logs.photo_camera_btn')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={triggerGallerySelect}
|
||||||
|
disabled={uploading}
|
||||||
|
style={{ width: 'auto', padding: '12px 20px', display: 'flex', gap: '8px', alignItems: 'center' }}
|
||||||
|
>
|
||||||
|
{uploading ? (
|
||||||
|
<span className="spin">⏳</span>
|
||||||
|
) : (
|
||||||
|
<Image size={16} />
|
||||||
|
)}
|
||||||
|
{uploading ? t('logs.photo_processing') : t('logs.photo_gallery_btn')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="photo-btn-delete"
|
className="btn primary"
|
||||||
onClick={() => handleDelete(photo.payloadId)}
|
onClick={triggerGallerySelect}
|
||||||
title="Remove photo"
|
disabled={uploading}
|
||||||
|
style={{ width: 'auto', padding: '12px 24px', display: 'flex', gap: '8px', alignItems: 'center' }}
|
||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
{uploading ? (
|
||||||
|
<span className="spin">⏳</span>
|
||||||
|
) : (
|
||||||
|
<Camera size={16} />
|
||||||
|
)}
|
||||||
|
{uploading ? t('logs.photo_processing') : t('logs.photo_btn')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{photo.caption && (
|
|
||||||
<div className="photo-caption-bar">
|
|
||||||
<span>{photo.caption}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
|
|
||||||
|
{/* Photo Grid */}
|
||||||
|
{decryptedPhotos.length === 0 ? (
|
||||||
|
<div className="dashboard-status-msg">{t('logs.no_photos')}</div>
|
||||||
|
) : (
|
||||||
|
<div className="photo-attachments-grid">
|
||||||
|
{decryptedPhotos.map((photo) => (
|
||||||
|
<div
|
||||||
|
key={photo.payloadId}
|
||||||
|
className="photo-card glass"
|
||||||
|
onClick={() => setMaximizedPhoto(photo)}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<div className="photo-container">
|
||||||
|
<img src={photo.image} alt={photo.caption || 'Attachment'} loading="lazy" />
|
||||||
|
{!readOnly && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="photo-btn-delete"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleDelete(photo.payloadId)
|
||||||
|
}}
|
||||||
|
title="Remove photo"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{photo.caption && (
|
||||||
|
<div className="photo-caption-bar">
|
||||||
|
<span>{photo.caption}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{maximizedPhoto && createPortal(
|
||||||
|
<div
|
||||||
|
className="photo-maximized-overlay"
|
||||||
|
onClick={() => setMaximizedPhoto(null)}
|
||||||
|
>
|
||||||
|
<div className="photo-maximized-container" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="photo-maximized-close"
|
||||||
|
onClick={() => setMaximizedPhoto(null)}
|
||||||
|
aria-label={t('common.close') || 'Close'}
|
||||||
|
>
|
||||||
|
<X size={24} />
|
||||||
|
</button>
|
||||||
|
<img
|
||||||
|
src={maximizedPhoto.image}
|
||||||
|
alt={maximizedPhoto.caption || 'Maximized Attachment'}
|
||||||
|
className="photo-maximized-img"
|
||||||
|
/>
|
||||||
|
{maximizedPhoto.caption && (
|
||||||
|
<div className="photo-maximized-caption">
|
||||||
|
{maximizedPhoto.caption}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { ChevronDown } from 'lucide-react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface ProfileAccordionSectionProps {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
icon?: ReactNode
|
||||||
|
defaultOpen?: boolean
|
||||||
|
/** When set, forces the section open (e.g. during onboarding tour). */
|
||||||
|
forceOpen?: boolean
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProfileAccordionSection({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
defaultOpen = false,
|
||||||
|
forceOpen,
|
||||||
|
children
|
||||||
|
}: ProfileAccordionSectionProps) {
|
||||||
|
const isOpen = forceOpen !== undefined ? forceOpen : defaultOpen
|
||||||
|
|
||||||
|
return (
|
||||||
|
<details className="profile-accordion" open={isOpen || undefined} data-section={id}>
|
||||||
|
<summary className="profile-accordion__summary">
|
||||||
|
<span className="profile-accordion__title">
|
||||||
|
{icon}
|
||||||
|
<span>{title}</span>
|
||||||
|
</span>
|
||||||
|
<ChevronDown size={20} className="profile-accordion__chevron" aria-hidden="true" />
|
||||||
|
</summary>
|
||||||
|
<div className="profile-accordion__body">{children}</div>
|
||||||
|
</details>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { User } from 'lucide-react'
|
||||||
|
|
||||||
|
interface ProfileHeaderButtonProps {
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProfileHeaderButton({ onClick }: ProfileHeaderButtonProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const username = localStorage.getItem('active_username') || 'Skipper'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-icon skipper-badge"
|
||||||
|
onClick={onClick}
|
||||||
|
title={t('dashboard.open_profile', { name: username })}
|
||||||
|
aria-label={t('dashboard.open_profile', { name: username })}
|
||||||
|
data-tour="nav-profile"
|
||||||
|
>
|
||||||
|
<User size={18} aria-hidden="true" />
|
||||||
|
<span className="skipper-badge__name">{username}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,7 +6,8 @@ import {
|
|||||||
enableCollaboratorChangePush,
|
enableCollaboratorChangePush,
|
||||||
fetchPushPrefs,
|
fetchPushPrefs,
|
||||||
getNotificationPermission,
|
getNotificationPermission,
|
||||||
isPushSupported
|
isPushSupported,
|
||||||
|
preloadPushService
|
||||||
} from '../services/pushNotifications.js'
|
} from '../services/pushNotifications.js'
|
||||||
import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
|
import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
@@ -28,6 +29,7 @@ export default function PushNotificationSettings() {
|
|||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
void preloadPushService()
|
||||||
try {
|
try {
|
||||||
const prefs = await fetchPushPrefs()
|
const prefs = await fetchPushPrefs()
|
||||||
setEnabled(prefs.collaboratorChangesEnabled)
|
setEnabled(prefs.collaboratorChangesEnabled)
|
||||||
@@ -56,7 +58,8 @@ 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')
|
console.error('Failed to toggle push notifications:', err)
|
||||||
|
const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err)
|
||||||
showAlert(message)
|
showAlert(message)
|
||||||
void loadPrefs()
|
void loadPrefs()
|
||||||
} finally {
|
} finally {
|
||||||
@@ -69,10 +72,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 +86,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 +125,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,11 +1,19 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { isGermanLocale } from '../utils/i18nLanguages.js'
|
||||||
|
import LanguageDropdown from './LanguageDropdown.tsx'
|
||||||
import { decryptJson } from '../services/crypto.js'
|
import { decryptJson } from '../services/crypto.js'
|
||||||
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
import VesselForm from './VesselForm.tsx'
|
import LogbookVesselPicker from './LogbookVesselPicker.tsx'
|
||||||
import CrewForm from './CrewForm.tsx'
|
import LogbookCrewPicker from './LogbookCrewPicker.tsx'
|
||||||
|
import type { LogbookVesselSelectionData } from '../types/vessel.js'
|
||||||
|
import { emptyLogbookVesselSelection } from '../types/vessel.js'
|
||||||
|
import type { LogbookCrewSelectionData } from '../types/person.js'
|
||||||
|
import { emptyLogbookCrewSelection } from '../types/person.js'
|
||||||
|
import { legacyCrewRecordsToLogbookSelection } from '../utils/personSnapshots.js'
|
||||||
|
import type { PersonData } from '../types/person.js'
|
||||||
import LogEntriesList from './LogEntriesList.tsx'
|
import LogEntriesList from './LogEntriesList.tsx'
|
||||||
import { Ship, Users, FileText, Lock, AlertCircle, Globe } from 'lucide-react'
|
import { Ship, Users, FileText, Lock, AlertCircle } from 'lucide-react'
|
||||||
|
|
||||||
interface ReadOnlyViewerProps {
|
interface ReadOnlyViewerProps {
|
||||||
token: string
|
token: string
|
||||||
@@ -30,9 +38,16 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
|||||||
// Logbook data states
|
// Logbook data states
|
||||||
const [logbookTitle, setLogbookTitle] = useState('Logbook')
|
const [logbookTitle, setLogbookTitle] = useState('Logbook')
|
||||||
const [yacht, setYacht] = useState<any>(null)
|
const [yacht, setYacht] = useState<any>(null)
|
||||||
const [crews, setCrews] = useState<any[]>([])
|
const [logbookCrewSelection, setLogbookCrewSelection] = useState<LogbookCrewSelectionData>(
|
||||||
|
emptyLogbookCrewSelection()
|
||||||
|
)
|
||||||
|
const [logbookVesselSelection, setLogbookVesselSelection] = useState<LogbookVesselSelectionData>(
|
||||||
|
emptyLogbookVesselSelection()
|
||||||
|
)
|
||||||
|
const [legacyCrews, setLegacyCrews] = useState<any[]>([])
|
||||||
const [entries, setEntries] = useState<any[]>([])
|
const [entries, setEntries] = useState<any[]>([])
|
||||||
const [photos, setPhotos] = useState<any[]>([])
|
const [photos, setPhotos] = useState<any[]>([])
|
||||||
|
const [voiceMemos, setVoiceMemos] = useState<any[]>([])
|
||||||
const [gpsTracks, setGpsTracks] = useState<any[]>([])
|
const [gpsTracks, setGpsTracks] = useState<any[]>([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -48,9 +63,9 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
|||||||
const res = await fetch(`/api/collaboration/share-pull?token=${token}`)
|
const res = await fetch(`/api/collaboration/share-pull?token=${token}`)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
if (res.status === 410) {
|
if (res.status === 410) {
|
||||||
throw new Error(i18n.language.startsWith('de') ? 'Dieser Freigabelink ist abgelaufen.' : 'This share link has expired.')
|
throw new Error(isGermanLocale(i18n.language) ? 'Dieser Freigabelink ist abgelaufen.' : 'This share link has expired.')
|
||||||
}
|
}
|
||||||
throw new Error(i18n.language.startsWith('de') ? 'Fehler beim Laden des freigegebenen Logbuchs.' : 'Failed to fetch shared logbook data.')
|
throw new Error(isGermanLocale(i18n.language) ? 'Fehler beim Laden des freigegebenen Logbuchs.' : 'Failed to fetch shared logbook data.')
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
@@ -70,18 +85,67 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
|||||||
}
|
}
|
||||||
setYacht(decYacht)
|
setYacht(decYacht)
|
||||||
|
|
||||||
// Decrypt Crews
|
if (data.logbookCrewSelection) {
|
||||||
const decCrews = []
|
const decSel = await decryptJson(
|
||||||
if (data.crews) {
|
data.logbookCrewSelection.encryptedData,
|
||||||
for (const c of data.crews) {
|
data.logbookCrewSelection.iv,
|
||||||
const dec = await decryptJson(c.encryptedData, c.iv, c.tag, keyBuffer)
|
data.logbookCrewSelection.tag,
|
||||||
decCrews.push({
|
keyBuffer
|
||||||
payloadId: c.payloadId,
|
)
|
||||||
data: dec
|
if (decSel) {
|
||||||
|
setLogbookCrewSelection({
|
||||||
|
activeSkipperId: decSel.activeSkipperId ?? null,
|
||||||
|
activeCrewIds: Array.isArray(decSel.activeCrewIds) ? decSel.activeCrewIds : [],
|
||||||
|
snapshotsById:
|
||||||
|
decSel.snapshotsById && typeof decSel.snapshotsById === 'object'
|
||||||
|
? decSel.snapshotsById
|
||||||
|
: {}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setCrews(decCrews)
|
|
||||||
|
if (data.logbookVesselSelection) {
|
||||||
|
const decVessel = await decryptJson(
|
||||||
|
data.logbookVesselSelection.encryptedData,
|
||||||
|
data.logbookVesselSelection.iv,
|
||||||
|
data.logbookVesselSelection.tag,
|
||||||
|
keyBuffer
|
||||||
|
)
|
||||||
|
if (decVessel) {
|
||||||
|
setLogbookVesselSelection({
|
||||||
|
activeVesselId: decVessel.activeVesselId ?? null,
|
||||||
|
vesselSnapshot: decVessel.vesselSnapshot ?? null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (decYacht) {
|
||||||
|
const legacy = decYacht as Record<string, unknown>
|
||||||
|
setLogbookVesselSelection({
|
||||||
|
activeVesselId: 'legacy-yacht',
|
||||||
|
vesselSnapshot: {
|
||||||
|
id: 'legacy-yacht',
|
||||||
|
name: typeof legacy.name === 'string' ? legacy.name : '',
|
||||||
|
...legacy
|
||||||
|
} as import('../types/vessel.js').VesselSnapshot
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const decCrews: Array<{ payloadId: string; data: PersonData }> = []
|
||||||
|
if (data.crews) {
|
||||||
|
for (const c of data.crews) {
|
||||||
|
const dec = await decryptJson(c.encryptedData, c.iv, c.tag, keyBuffer)
|
||||||
|
if (dec) {
|
||||||
|
decCrews.push({
|
||||||
|
payloadId: c.payloadId,
|
||||||
|
data: dec as PersonData
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setLegacyCrews(decCrews)
|
||||||
|
|
||||||
|
if (!data.logbookCrewSelection && decCrews.length > 0) {
|
||||||
|
setLogbookCrewSelection(legacyCrewRecordsToLogbookSelection(decCrews))
|
||||||
|
}
|
||||||
|
|
||||||
// Decrypt Entries
|
// Decrypt Entries
|
||||||
const decEntries = []
|
const decEntries = []
|
||||||
@@ -112,6 +176,23 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
|||||||
}
|
}
|
||||||
setPhotos(decPhotos)
|
setPhotos(decPhotos)
|
||||||
|
|
||||||
|
const decVoiceMemos = []
|
||||||
|
if (data.voiceMemos) {
|
||||||
|
for (const v of data.voiceMemos) {
|
||||||
|
const dec = await decryptJson(v.encryptedData, v.iv, v.tag, keyBuffer)
|
||||||
|
if (dec) {
|
||||||
|
decVoiceMemos.push({
|
||||||
|
payloadId: v.payloadId,
|
||||||
|
audio: dec.audio,
|
||||||
|
mimeType: dec.mimeType,
|
||||||
|
durationSec: dec.durationSec,
|
||||||
|
caption: dec.caption || ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setVoiceMemos(decVoiceMemos)
|
||||||
|
|
||||||
// Decrypt GPS Tracks
|
// Decrypt GPS Tracks
|
||||||
const decGpsTracks = []
|
const decGpsTracks = []
|
||||||
if (data.gpsTracks) {
|
if (data.gpsTracks) {
|
||||||
@@ -135,16 +216,12 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleLanguage = () => {
|
|
||||||
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
|
|
||||||
i18n.changeLanguage(nextLang)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="tab-placeholder" style={{ height: '100vh', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center' }}>
|
<div className="tab-placeholder" style={{ height: '100vh', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center' }}>
|
||||||
<Ship className="header-logo spin" size={48} />
|
<Ship className="header-logo spin" size={48} />
|
||||||
<p>{i18n.language.startsWith('de') ? 'Lade freigegebenes Logbuch...' : 'Loading shared logbook...'}</p>
|
<p>{isGermanLocale(i18n.language) ? 'Lade freigegebenes Logbuch...' : 'Loading shared logbook...'}</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -153,10 +230,10 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
|||||||
return (
|
return (
|
||||||
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', padding: '20px', textAlign: 'center' }}>
|
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', padding: '20px', textAlign: 'center' }}>
|
||||||
<AlertCircle size={48} style={{ color: '#ef4444', marginBottom: '16px' }} />
|
<AlertCircle size={48} style={{ color: '#ef4444', marginBottom: '16px' }} />
|
||||||
<h2 style={{ color: '#f1f5f9', marginBottom: '8px' }}>{i18n.language.startsWith('de') ? 'Verbindungsfehler' : 'Access Error'}</h2>
|
<h2 style={{ color: '#f1f5f9', marginBottom: '8px' }}>{isGermanLocale(i18n.language) ? 'Verbindungsfehler' : 'Access Error'}</h2>
|
||||||
<p style={{ color: '#94a3b8', maxWidth: '400px', marginBottom: '24px' }}>{error}</p>
|
<p style={{ color: '#94a3b8', maxWidth: '400px', marginBottom: '24px' }}>{error}</p>
|
||||||
<button className="btn primary" onClick={loadData} style={{ width: 'auto' }}>
|
<button className="btn primary" onClick={loadData} style={{ width: 'auto' }}>
|
||||||
{i18n.language.startsWith('de') ? 'Erneut versuchen' : 'Retry'}
|
{isGermanLocale(i18n.language) ? 'Erneut versuchen' : 'Retry'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -173,16 +250,13 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
|||||||
<h2>{logbookTitle}</h2>
|
<h2>{logbookTitle}</h2>
|
||||||
<p className="app-subtitle" style={{ color: '#10b981', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
<p className="app-subtitle" style={{ color: '#10b981', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
<Lock size={12} />
|
<Lock size={12} />
|
||||||
<span>{i18n.language.startsWith('de') ? 'Schreibgeschützte Ansicht (Ende-zu-Ende verschlüsselt)' : 'Read-Only View (End-to-End Encrypted)'}</span>
|
<span>{isGermanLocale(i18n.language) ? 'Schreibgeschützte Ansicht (Ende-zu-Ende verschlüsselt)' : 'Read-Only View (End-to-End Encrypted)'}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
<button className="btn secondary" onClick={toggleLanguage} style={{ width: 'auto', padding: '6px 12px', fontSize: '13px' }}>
|
<LanguageDropdown variant="secondary-button" align="right" />
|
||||||
<Globe size={14} style={{ marginRight: '4px' }} />
|
|
||||||
{i18n.language.startsWith('de') ? 'English' : 'Deutsch'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -221,23 +295,27 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
|
|||||||
preloadedYacht={yacht}
|
preloadedYacht={yacht}
|
||||||
preloadedEntries={entries}
|
preloadedEntries={entries}
|
||||||
preloadedPhotos={photos}
|
preloadedPhotos={photos}
|
||||||
|
preloadedVoiceMemos={voiceMemos}
|
||||||
preloadedGpsTracks={gpsTracks}
|
preloadedGpsTracks={gpsTracks}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'vessel' && (
|
{activeTab === 'vessel' && (
|
||||||
<VesselForm
|
<LogbookVesselPicker
|
||||||
logbookId="shared"
|
logbookId="shared"
|
||||||
readOnly={true}
|
readOnly={true}
|
||||||
preloadedData={yacht}
|
selectionOnly={true}
|
||||||
|
preloadedSelection={logbookVesselSelection}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'crew' && (
|
{activeTab === 'crew' && (
|
||||||
<CrewForm
|
<LogbookCrewPicker
|
||||||
logbookId="shared"
|
logbookId="shared"
|
||||||
readOnly={true}
|
readOnly={true}
|
||||||
preloadedData={crews}
|
selectionOnly={true}
|
||||||
|
preloadedPool={legacyCrews.length > 0 ? legacyCrews : undefined}
|
||||||
|
preloadedSelection={logbookCrewSelection}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -28,19 +28,19 @@ export default function RegistrationDisclaimer({
|
|||||||
className={`auth-card glass registration-disclaimer${variant === 'view' ? ' registration-disclaimer--modal' : ''}`}
|
className={`auth-card glass registration-disclaimer${variant === 'view' ? ' registration-disclaimer--modal' : ''}`}
|
||||||
role="document"
|
role="document"
|
||||||
>
|
>
|
||||||
|
{variant === 'view' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="registration-disclaimer__close"
|
||||||
|
onClick={onDismiss}
|
||||||
|
aria-label={t('disclaimer.close')}
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<div className="auth-header">
|
<div className="auth-header">
|
||||||
<ScrollText className="auth-icon accent" size={48} />
|
<ScrollText className="auth-icon accent" size={48} />
|
||||||
<h2>{t('disclaimer.title')}</h2>
|
<h2>{t('disclaimer.title')}</h2>
|
||||||
{variant === 'view' && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="registration-disclaimer__close"
|
|
||||||
onClick={onDismiss}
|
|
||||||
aria-label={t('disclaimer.close')}
|
|
||||||
>
|
|
||||||
<X size={18} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="registration-disclaimer__intro">{t('disclaimer.intro')}</p>
|
<p className="registration-disclaimer__intro">{t('disclaimer.intro')}</p>
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
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 AccountDangerZone from './AccountDangerZone.tsx'
|
import LinkQrCode from './LinkQrCode.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,
|
||||||
|
preloadPushService
|
||||||
|
} from '../services/pushNotifications.js'
|
||||||
|
import { isIosDevice, isRunningStandalone } from '../hooks/usePwaInstall.js'
|
||||||
|
|
||||||
interface SettingsFormProps {
|
interface SettingsFormProps {
|
||||||
logbookId?: string | null
|
logbookId?: string | null
|
||||||
@@ -26,7 +28,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'))
|
||||||
@@ -36,14 +37,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('')
|
||||||
@@ -52,7 +46,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)
|
||||||
@@ -63,6 +56,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
|||||||
loadCollaborators()
|
loadCollaborators()
|
||||||
loadShareLink()
|
loadShareLink()
|
||||||
}
|
}
|
||||||
|
void preloadPushService()
|
||||||
}, [logbookId])
|
}, [logbookId])
|
||||||
|
|
||||||
const loadShareLink = async () => {
|
const loadShareLink = async () => {
|
||||||
@@ -121,9 +115,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)
|
||||||
}
|
}
|
||||||
@@ -137,7 +131,6 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const loadCollaborators = async () => {
|
const loadCollaborators = async () => {
|
||||||
setLoadingCollabs(true)
|
setLoadingCollabs(true)
|
||||||
setCollabError(null)
|
setCollabError(null)
|
||||||
@@ -167,6 +160,44 @@ 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)
|
||||||
|
const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err)
|
||||||
|
await showAlert(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleGenerateInvite = async () => {
|
const handleGenerateInvite = async () => {
|
||||||
if (!logbookId) return
|
if (!logbookId) return
|
||||||
setGeneratingInvite(true)
|
setGeneratingInvite(true)
|
||||||
@@ -174,10 +205,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' })
|
||||||
@@ -188,16 +217,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)
|
||||||
}
|
}
|
||||||
@@ -226,40 +254,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 (
|
||||||
@@ -268,128 +282,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' }}>
|
||||||
@@ -401,6 +299,10 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
|||||||
{t('settings.share_desc')}
|
{t('settings.share_desc')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p className="signature-lock-notice" style={{ marginBottom: '16px' }}>
|
||||||
|
{t('settings.share_privacy_warning')}
|
||||||
|
</p>
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '20px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '20px' }}>
|
||||||
<label className="switch-label" style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer', fontSize: '14px', color: '#f1f5f9' }}>
|
<label className="switch-label" style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer', fontSize: '14px', color: '#f1f5f9' }}>
|
||||||
<input
|
<input
|
||||||
@@ -416,34 +318,36 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{shareEnabled && shareLink && (
|
{shareEnabled && shareLink && (
|
||||||
<div className="input-group mb-4 copy-link-row">
|
<div className="link-with-qr mb-4">
|
||||||
<input
|
<div className="input-group copy-link-row">
|
||||||
type="text"
|
<input
|
||||||
readOnly
|
type="text"
|
||||||
value={shareLink}
|
readOnly
|
||||||
className="input-text font-mono text-xs"
|
value={shareLink}
|
||||||
style={{ flex: 1, padding: '10px' }}
|
className="input-text font-mono text-xs"
|
||||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
style={{ flex: 1, padding: '10px' }}
|
||||||
/>
|
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||||
<button
|
/>
|
||||||
type="button"
|
<button
|
||||||
className="btn secondary"
|
type="button"
|
||||||
onClick={handleCopyShareLink}
|
className="btn secondary"
|
||||||
style={{ width: 'auto', padding: '10px' }}
|
onClick={handleCopyShareLink}
|
||||||
>
|
style={{ width: 'auto', padding: '10px' }}
|
||||||
{shareCopied ? <Check size={16} /> : <Copy size={16} />}
|
title={t('settings.share_copy_btn')}
|
||||||
</button>
|
>
|
||||||
|
{shareCopied ? <Check size={16} /> : <Copy size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<LinkQrCode value={shareLink} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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' }}>
|
||||||
@@ -471,27 +375,30 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{inviteLink && (
|
{inviteLink && (
|
||||||
<div className="input-group mb-6 copy-link-row">
|
<div className="link-with-qr mb-6">
|
||||||
<input
|
<div className="input-group copy-link-row">
|
||||||
type="text"
|
<input
|
||||||
readOnly
|
type="text"
|
||||||
value={inviteLink}
|
readOnly
|
||||||
className="input-text font-mono text-xs"
|
value={inviteLink}
|
||||||
style={{ flex: 1, padding: '10px' }}
|
className="input-text font-mono text-xs"
|
||||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
style={{ flex: 1, padding: '10px' }}
|
||||||
/>
|
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||||
<button
|
/>
|
||||||
type="button"
|
<button
|
||||||
className="btn secondary"
|
type="button"
|
||||||
onClick={handleCopyInvite}
|
className="btn secondary"
|
||||||
style={{ width: 'auto', padding: '10px' }}
|
onClick={handleCopyInvite}
|
||||||
>
|
style={{ width: 'auto', padding: '10px' }}
|
||||||
{inviteCopied ? <Check size={16} /> : <Copy size={16} />}
|
title={t('settings.share_copy_btn')}
|
||||||
</button>
|
>
|
||||||
|
{inviteCopied ? <Check size={16} /> : <Copy size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<LinkQrCode value={inviteLink} />
|
||||||
</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>
|
||||||
@@ -522,7 +429,7 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
|||||||
<td>
|
<td>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn-icon logout"
|
className="btn-icon danger"
|
||||||
onClick={() => handleRevoke(c.id, c.username)}
|
onClick={() => handleRevoke(c.id, c.username)}
|
||||||
title="Revoke access"
|
title="Revoke access"
|
||||||
>
|
>
|
||||||
@@ -537,8 +444,6 @@ export default function SettingsForm({ logbookId, onLogbookRestored }: SettingsF
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Danger Zone / Account Deletion */}
|
|
||||||
<AccountDangerZone className="mt-6" />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import SignaturePad from './SignaturePad.tsx'
|
|||||||
import PasskeySignButton from './PasskeySignButton.tsx'
|
import PasskeySignButton from './PasskeySignButton.tsx'
|
||||||
import type { PasskeySignature, SignatureValue } from '../types/signatures.js'
|
import type { PasskeySignature, SignatureValue } from '../types/signatures.js'
|
||||||
import { isPasskeySignature, getSignaturePayload, getSignatureAttribution } from '../utils/signatures.js'
|
import { isPasskeySignature, getSignaturePayload, getSignatureAttribution } from '../utils/signatures.js'
|
||||||
|
import { formatAppDateTime } from '../utils/dateTimeFormat.js'
|
||||||
|
|
||||||
type SignatureMode = 'passkey' | 'classic'
|
type SignatureMode = 'passkey' | 'classic'
|
||||||
|
|
||||||
@@ -30,9 +31,7 @@ function SignerAttributionBadge({ value }: { value: SignatureValue | '' }) {
|
|||||||
const attribution = getSignatureAttribution(value)
|
const attribution = getSignatureAttribution(value)
|
||||||
if (!attribution) return null
|
if (!attribution) return null
|
||||||
|
|
||||||
const formattedDate = new Date(attribution.signedAt).toLocaleString(
|
const formattedDate = formatAppDateTime(attribution.signedAt, i18n.language)
|
||||||
i18n.language === 'de' ? 'de-DE' : 'en-GB'
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="passkey-sign-badge valid signature-attribution-badge">
|
<div className="passkey-sign-badge valid signature-attribution-badge">
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ import {
|
|||||||
} from '../services/statsAggregation.js'
|
} from '../services/statsAggregation.js'
|
||||||
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
|
import { compareTravelDaysChronological } from '../utils/logEntryTankLevels.js'
|
||||||
import { formatFuelPerMotorHour } from '../utils/fuelStats.js'
|
import { formatFuelPerMotorHour } from '../utils/fuelStats.js'
|
||||||
|
import { formatAppDecimal } from '../utils/numberFormat.js'
|
||||||
|
import {
|
||||||
|
loadLogbookEventSeries,
|
||||||
|
type EventSeriesPoint,
|
||||||
|
type EventSeriesSummary
|
||||||
|
} from '../services/eventSeriesAggregation.js'
|
||||||
|
|
||||||
interface StatsDashboardProps {
|
interface StatsDashboardProps {
|
||||||
logbookId: string
|
logbookId: string
|
||||||
@@ -206,8 +212,8 @@ function PropulsionBreakdown({ totals }: { totals: StatsTotals }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="stats-propulsion-labels">
|
<div className="stats-propulsion-labels">
|
||||||
<span>{t('stats.sail_distance')}: {formatNm(totals.sailDistanceNm)} {t('stats.unit_nm')} ({sailPct.toFixed(0)}%)</span>
|
<span>{t('stats.sail_distance')}: {formatNm(totals.sailDistanceNm)} {t('stats.unit_nm')} ({formatAppDecimal(sailPct, { maximumFractionDigits: 0 })}%)</span>
|
||||||
<span>{t('stats.motor_distance')}: {formatNm(totals.motorDistanceNm)} {t('stats.unit_nm')} ({motorPct.toFixed(0)}%)</span>
|
<span>{t('stats.motor_distance')}: {formatNm(totals.motorDistanceNm)} {t('stats.unit_nm')} ({formatAppDecimal(motorPct, { maximumFractionDigits: 0 })}%)</span>
|
||||||
{totals.unknownPropulsionNm > 0 && (
|
{totals.unknownPropulsionNm > 0 && (
|
||||||
<span>{t('stats.unknown_propulsion')}: {formatNm(totals.unknownPropulsionNm)} {t('stats.unit_nm')}</span>
|
<span>{t('stats.unknown_propulsion')}: {formatNm(totals.unknownPropulsionNm)} {t('stats.unit_nm')}</span>
|
||||||
)}
|
)}
|
||||||
@@ -217,7 +223,62 @@ function PropulsionBreakdown({ totals }: { totals: StatsTotals }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function LogbookScopeView({ summary }: { summary: LogbookStatsSummary }) {
|
function EventSeriesList({ title, points, emptyLabel }: { title: string; points: EventSeriesPoint[]; emptyLabel: string }) {
|
||||||
|
if (points.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="stats-event-series-block">
|
||||||
|
<h4 className="stats-section-subtitle">{title}</h4>
|
||||||
|
<p className="stats-section-sub">{emptyLabel}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stats-event-series-block">
|
||||||
|
<h4 className="stats-section-subtitle">{title}</h4>
|
||||||
|
<ul className="stats-event-series-list">
|
||||||
|
{points.map((point, idx) => (
|
||||||
|
<li key={`${point.entryId}-${point.time}-${idx}`} className="stats-event-series-item">
|
||||||
|
<span className="stats-event-series-when">
|
||||||
|
{new Date(point.date).toLocaleDateString(undefined, { day: '2-digit', month: '2-digit' })}
|
||||||
|
{' · '}
|
||||||
|
{point.time}
|
||||||
|
</span>
|
||||||
|
<span className="stats-event-series-value">{point.summary}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EventSeriesPanel({ series }: { series: EventSeriesSummary }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const motorPoints = series.motor.map((point) => ({
|
||||||
|
...point,
|
||||||
|
summary: point.summary === 'start'
|
||||||
|
? t('logs.live_motor_start')
|
||||||
|
: t('logs.live_motor_stop')
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="member-editor-card glass mt-6">
|
||||||
|
<h3 className="stats-section-title">{t('stats.event_series_title')}</h3>
|
||||||
|
<p className="stats-section-sub">{t('stats.event_series_hint')}</p>
|
||||||
|
<EventSeriesList title={t('stats.event_series_pressure')} points={series.pressure} emptyLabel={t('stats.event_series_empty')} />
|
||||||
|
<EventSeriesList title={t('stats.event_series_wind')} points={series.wind} emptyLabel={t('stats.event_series_empty')} />
|
||||||
|
<EventSeriesList title={t('stats.event_series_motor')} points={motorPoints} emptyLabel={t('stats.event_series_empty')} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LogbookScopeView({
|
||||||
|
summary,
|
||||||
|
eventSeries
|
||||||
|
}: {
|
||||||
|
summary: LogbookStatsSummary
|
||||||
|
eventSeries: EventSeriesSummary | null
|
||||||
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { travelDays, routePorts, trackSegments, totals } = summary
|
const { travelDays, routePorts, trackSegments, totals } = summary
|
||||||
|
|
||||||
@@ -313,6 +374,8 @@ function LogbookScopeView({ summary }: { summary: LogbookStatsSummary }) {
|
|||||||
<h3 className="stats-section-title">{t('stats.propulsion_title')}</h3>
|
<h3 className="stats-section-title">{t('stats.propulsion_title')}</h3>
|
||||||
<PropulsionBreakdown totals={totals} />
|
<PropulsionBreakdown totals={totals} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{eventSeries && <EventSeriesPanel series={eventSeries} />}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -323,18 +386,21 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [logbookStats, setLogbookStats] = useState<LogbookStatsSummary | null>(null)
|
const [logbookStats, setLogbookStats] = useState<LogbookStatsSummary | null>(null)
|
||||||
|
const [eventSeries, setEventSeries] = useState<EventSeriesSummary | null>(null)
|
||||||
const [accountStats, setAccountStats] = useState<Awaited<ReturnType<typeof loadAccountStats>> | null>(null)
|
const [accountStats, setAccountStats] = useState<Awaited<ReturnType<typeof loadAccountStats>> | null>(null)
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const [lb, acc] = await Promise.all([
|
const [lb, acc, series] = await Promise.all([
|
||||||
loadLogbookStats(logbookId, logbookTitle, true),
|
loadLogbookStats(logbookId, logbookTitle, true),
|
||||||
loadAccountStats(false)
|
loadAccountStats(false),
|
||||||
|
loadLogbookEventSeries(logbookId)
|
||||||
])
|
])
|
||||||
setLogbookStats(lb)
|
setLogbookStats(lb)
|
||||||
setAccountStats(acc)
|
setAccountStats(acc)
|
||||||
|
setEventSeries(series)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('Failed to load statistics:', err)
|
console.error('Failed to load statistics:', err)
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load statistics.')
|
setError(err instanceof Error ? err.message : 'Failed to load statistics.')
|
||||||
@@ -397,7 +463,7 @@ export default function StatsDashboard({ logbookId, logbookTitle }: StatsDashboa
|
|||||||
<p>{t('stats.loading')}</p>
|
<p>{t('stats.loading')}</p>
|
||||||
</div>
|
</div>
|
||||||
) : scope === 'logbook' && logbookStats ? (
|
) : scope === 'logbook' && logbookStats ? (
|
||||||
<LogbookScopeView summary={logbookStats} />
|
<LogbookScopeView summary={logbookStats} eventSeries={eventSeries} />
|
||||||
) : scope === 'account' && accountStats ? (
|
) : scope === 'account' && accountStats ? (
|
||||||
<>
|
<>
|
||||||
<TotalsGrid totals={accountStats.totals} />
|
<TotalsGrid totals={accountStats.totals} />
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { AlertTriangle } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
getSyncConflicts,
|
||||||
|
subscribeSyncConflicts,
|
||||||
|
type SyncConflict
|
||||||
|
} from '../services/syncConflicts.js'
|
||||||
|
import {
|
||||||
|
resolveSyncConflictKeepLocal,
|
||||||
|
resolveSyncConflictUseServer
|
||||||
|
} from '../services/sync.js'
|
||||||
|
|
||||||
|
interface SyncConflictBannerProps {
|
||||||
|
logbookId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SyncConflictBanner({ logbookId }: SyncConflictBannerProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [items, setItems] = useState<SyncConflict[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const refresh = () => {
|
||||||
|
setItems(logbookId ? getSyncConflicts(logbookId) : getSyncConflicts())
|
||||||
|
}
|
||||||
|
refresh()
|
||||||
|
return subscribeSyncConflicts(refresh)
|
||||||
|
}, [logbookId])
|
||||||
|
|
||||||
|
if (items.length === 0) return null
|
||||||
|
|
||||||
|
const first = items[0]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sync-conflict-banner" role="alert">
|
||||||
|
<AlertTriangle size={20} aria-hidden />
|
||||||
|
<div className="sync-conflict-banner__body">
|
||||||
|
<strong>{t('sync.conflict_title')}</strong>
|
||||||
|
<p>
|
||||||
|
{t('sync.conflict_message', {
|
||||||
|
count: items.length,
|
||||||
|
id: first.payloadId.slice(0, 8)
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<div className="sync-conflict-banner__actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={() => void resolveSyncConflictUseServer(first)}
|
||||||
|
>
|
||||||
|
{t('sync.conflict_use_server')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn primary"
|
||||||
|
onClick={() => void resolveSyncConflictKeepLocal(first)}
|
||||||
|
>
|
||||||
|
{t('sync.conflict_keep_local')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import React, { useCallback } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { clampTankLiters } from '../utils/tankCapacity.js'
|
||||||
|
import { formatTankLiters, parseAppDecimalOrZero } from '../utils/numberFormat.js'
|
||||||
|
|
||||||
|
interface TankLiterInputProps {
|
||||||
|
id?: string
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
maxLiters?: number
|
||||||
|
disabled?: boolean
|
||||||
|
titleTooltip?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseInputLiters(value: string): number {
|
||||||
|
if (!value.trim()) return 0
|
||||||
|
return parseAppDecimalOrZero(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = formatTankLiters(clamped)
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,844 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useLiveQuery } from 'dexie-react-hooks'
|
||||||
|
import { useSyncIndicator } from '../hooks/useSyncIndicator.js'
|
||||||
|
import {
|
||||||
|
User,
|
||||||
|
ChevronLeft,
|
||||||
|
LogOut,
|
||||||
|
KeyRound,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
BookOpen,
|
||||||
|
Anchor,
|
||||||
|
Gauge,
|
||||||
|
Sailboat,
|
||||||
|
Ship,
|
||||||
|
Timer,
|
||||||
|
Share2,
|
||||||
|
Calendar,
|
||||||
|
Lock,
|
||||||
|
BarChart2,
|
||||||
|
Shield,
|
||||||
|
Smartphone,
|
||||||
|
RefreshCw,
|
||||||
|
Wifi,
|
||||||
|
WifiOff,
|
||||||
|
CircleCheck,
|
||||||
|
CircleAlert
|
||||||
|
} from 'lucide-react'
|
||||||
|
import AccountDangerZone from './AccountDangerZone.tsx'
|
||||||
|
import UserProfilePreferences from './UserProfilePreferences.tsx'
|
||||||
|
import PersonPoolForm from './PersonPoolForm.tsx'
|
||||||
|
import VesselPoolForm from './VesselPoolForm.tsx'
|
||||||
|
import ProfileAccordionSection from './ProfileAccordionSection.tsx'
|
||||||
|
import { useAppTour } from '../context/AppTourContext.tsx'
|
||||||
|
import BetaBadge from './BetaBadge.tsx'
|
||||||
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
|
import {
|
||||||
|
addPasskey,
|
||||||
|
fetchUserProfile,
|
||||||
|
forgetUsername,
|
||||||
|
getActiveMasterKey,
|
||||||
|
getKnownUsernames,
|
||||||
|
hasLocalPin,
|
||||||
|
removeLocalPin,
|
||||||
|
removePasskey,
|
||||||
|
renamePasskey,
|
||||||
|
rotateRecoveryPhrase,
|
||||||
|
setLocalPin,
|
||||||
|
type UserProfile
|
||||||
|
} from '../services/auth.js'
|
||||||
|
import {
|
||||||
|
formatHours,
|
||||||
|
formatNm,
|
||||||
|
loadAccountStats,
|
||||||
|
type AccountStatsSummary
|
||||||
|
} from '../services/statsAggregation.js'
|
||||||
|
import { db } from '../services/db.js'
|
||||||
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
|
|
||||||
|
interface UserProfilePageProps {
|
||||||
|
onBack: () => void
|
||||||
|
onLogout: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAccountAge(createdAt: string, locale: string): string {
|
||||||
|
const created = new Date(createdAt)
|
||||||
|
if (Number.isNaN(created.getTime())) return createdAt
|
||||||
|
return created.toLocaleDateString(locale, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiCard({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
unit
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
unit?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="stats-kpi-card glass">
|
||||||
|
<div className="stats-kpi-icon">{icon}</div>
|
||||||
|
<div className="stats-kpi-body">
|
||||||
|
<span className="stats-kpi-label">{label}</span>
|
||||||
|
<span className="stats-kpi-value">
|
||||||
|
{value}
|
||||||
|
{unit ? <span className="stats-kpi-unit">{unit}</span> : null}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SecurityCheckItem({ ok, label }: { ok: boolean; label: string }) {
|
||||||
|
return (
|
||||||
|
<li className={`profile-security-item ${ok ? 'profile-security-item--ok' : 'profile-security-item--warn'}`}>
|
||||||
|
{ok ? <CircleCheck size={18} aria-hidden="true" /> : <CircleAlert size={18} aria-hidden="true" />}
|
||||||
|
<span>{label}</span>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserProfilePage({ onBack, onLogout }: UserProfilePageProps) {
|
||||||
|
const { t, i18n } = useTranslation()
|
||||||
|
const { showConfirm, showAlert } = useDialog()
|
||||||
|
const username = localStorage.getItem('active_username') || 'Skipper'
|
||||||
|
|
||||||
|
const [profile, setProfile] = useState<UserProfile | null>(null)
|
||||||
|
const [accountStats, setAccountStats] = useState<AccountStatsSummary | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [copiedUserId, setCopiedUserId] = useState(false)
|
||||||
|
const [passkeyBusy, setPasskeyBusy] = useState(false)
|
||||||
|
const [pinBusy, setPinBusy] = useState(false)
|
||||||
|
const [pinInput, setPinInput] = useState('')
|
||||||
|
const [pinConfirm, setPinConfirm] = useState('')
|
||||||
|
const [pinActive, setPinActive] = useState(() => hasLocalPin(username))
|
||||||
|
const [newPasskeyLabel, setNewPasskeyLabel] = useState('')
|
||||||
|
const [passkeyLabels, setPasskeyLabels] = useState<Record<string, string>>({})
|
||||||
|
const [online, setOnline] = useState(navigator.onLine)
|
||||||
|
const [isKnownDevice, setIsKnownDevice] = useState(() =>
|
||||||
|
getKnownUsernames().some((u) => u.toLowerCase() === username.toLowerCase())
|
||||||
|
)
|
||||||
|
const [recoveryBusy, setRecoveryBusy] = useState(false)
|
||||||
|
const [pendingRecoveryPhrase, setPendingRecoveryPhrase] = useState<string | null>(null)
|
||||||
|
const [recoveryCopied, setRecoveryCopied] = useState(false)
|
||||||
|
|
||||||
|
const {
|
||||||
|
pendingCount: pendingSyncCount,
|
||||||
|
showSpinner,
|
||||||
|
showPendingWarning,
|
||||||
|
connStatusClassName
|
||||||
|
} = useSyncIndicator()
|
||||||
|
|
||||||
|
const { isActive: tourActive, currentStepId: tourStepId } = useAppTour()
|
||||||
|
const fleetSectionTourOpen =
|
||||||
|
tourActive &&
|
||||||
|
(tourStepId === 'profile_vessel_pool' || tourStepId === 'profile_crew_pool')
|
||||||
|
|
||||||
|
const sharedLogbookCount = useLiveQuery(
|
||||||
|
() => db.logbooks.filter((lb) => lb.isShared === 1).count(),
|
||||||
|
[]
|
||||||
|
) ?? 0
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const profileData = await fetchUserProfile()
|
||||||
|
setProfile(profileData)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stats = await loadAccountStats(false)
|
||||||
|
setAccountStats(stats)
|
||||||
|
} catch (statsErr) {
|
||||||
|
console.error('Failed to load account stats for profile:', statsErr)
|
||||||
|
setAccountStats(null)
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : t('profile.load_error'))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [t])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadData()
|
||||||
|
}, [loadData])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
trackPlausibleEvent(PlausibleEvents.PROFILE_OPENED)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleOnline = () => setOnline(true)
|
||||||
|
const handleOffline = () => setOnline(false)
|
||||||
|
window.addEventListener('online', handleOnline)
|
||||||
|
window.addEventListener('offline', handleOffline)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', handleOnline)
|
||||||
|
window.removeEventListener('offline', handleOffline)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!profile) return
|
||||||
|
const labels: Record<string, string> = {}
|
||||||
|
for (const cred of profile.credentials) {
|
||||||
|
labels[cred.id] = cred.label ?? ''
|
||||||
|
}
|
||||||
|
setPasskeyLabels(labels)
|
||||||
|
}, [profile])
|
||||||
|
|
||||||
|
const statsTotals = accountStats?.totals
|
||||||
|
const logbookCount =
|
||||||
|
accountStats?.logbooks.length ?? profile?.serverMeta.ownedLogbookCount ?? 0
|
||||||
|
|
||||||
|
const accountAgeLabel = useMemo(() => {
|
||||||
|
if (!profile?.createdAt) return '—'
|
||||||
|
return formatAccountAge(profile.createdAt, i18n.language)
|
||||||
|
}, [profile?.createdAt, i18n.language])
|
||||||
|
|
||||||
|
const handleCopyUserId = async () => {
|
||||||
|
if (!profile?.userId) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(profile.userId)
|
||||||
|
setCopiedUserId(true)
|
||||||
|
window.setTimeout(() => setCopiedUserId(false), 2000)
|
||||||
|
} catch {
|
||||||
|
showAlert(t('profile.copy_failed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddPasskey = async () => {
|
||||||
|
setPasskeyBusy(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const hadLabel = Boolean(newPasskeyLabel.trim())
|
||||||
|
await addPasskey(newPasskeyLabel)
|
||||||
|
setNewPasskeyLabel('')
|
||||||
|
await loadData()
|
||||||
|
trackPlausibleEvent(PlausibleEvents.PASSKEY_ADDED, { labeled: hadLabel })
|
||||||
|
showAlert(t('profile.add_passkey_success'))
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : t('profile.add_passkey_failed'))
|
||||||
|
} finally {
|
||||||
|
setPasskeyBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRenamePasskey = async (credentialId: string) => {
|
||||||
|
setPasskeyBusy(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await renamePasskey(credentialId, passkeyLabels[credentialId] ?? '')
|
||||||
|
await loadData()
|
||||||
|
trackPlausibleEvent(PlausibleEvents.PASSKEY_RENAMED)
|
||||||
|
showAlert(t('profile.passkey_rename_success'))
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : t('profile.passkey_rename_failed'))
|
||||||
|
} finally {
|
||||||
|
setPasskeyBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleForgetDevice = async () => {
|
||||||
|
const confirmed = await showConfirm(
|
||||||
|
t('profile.device_forget_confirm_desc'),
|
||||||
|
t('profile.device_forget_confirm_title'),
|
||||||
|
t('profile.device_forget_confirm_yes'),
|
||||||
|
t('profile.device_forget_confirm_no')
|
||||||
|
)
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
forgetUsername(username)
|
||||||
|
setIsKnownDevice(false)
|
||||||
|
trackPlausibleEvent(PlausibleEvents.DEVICE_FORGOTTEN)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemovePasskey = async (credentialId: string) => {
|
||||||
|
if (profile && profile.credentials.length <= 1) {
|
||||||
|
trackPlausibleEvent(PlausibleEvents.LAST_PASSKEY_REMOVE_HINTED)
|
||||||
|
await showAlert(
|
||||||
|
t('profile.remove_passkey_last_desc'),
|
||||||
|
t('profile.remove_passkey_last_title')
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmed = await showConfirm(
|
||||||
|
t('profile.remove_passkey_confirm_desc'),
|
||||||
|
t('profile.remove_passkey_confirm_title'),
|
||||||
|
t('profile.remove_passkey_confirm_yes'),
|
||||||
|
t('profile.remove_passkey_confirm_no')
|
||||||
|
)
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
setPasskeyBusy(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await removePasskey(credentialId)
|
||||||
|
await loadData()
|
||||||
|
trackPlausibleEvent(PlausibleEvents.PASSKEY_REMOVED)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : t('profile.remove_passkey_failed'))
|
||||||
|
} finally {
|
||||||
|
setPasskeyBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSavePin = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (pinInput.length < 4) {
|
||||||
|
setError(t('profile.pin_length_error'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (pinInput !== pinConfirm) {
|
||||||
|
setError(t('profile.pin_mismatch'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const masterKey = getActiveMasterKey()
|
||||||
|
if (!masterKey) {
|
||||||
|
setError(t('profile.pin_no_session'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const pinAction = pinActive ? 'change' : 'set'
|
||||||
|
|
||||||
|
setPinBusy(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await setLocalPin(pinInput.trim(), username, masterKey)
|
||||||
|
setPinActive(true)
|
||||||
|
setPinInput('')
|
||||||
|
setPinConfirm('')
|
||||||
|
trackPlausibleEvent(PlausibleEvents.LOCAL_PIN_SET, { action: pinAction })
|
||||||
|
showAlert(t('profile.pin_saved'))
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : t('profile.pin_save_failed'))
|
||||||
|
} finally {
|
||||||
|
setPinBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemovePin = async () => {
|
||||||
|
const confirmed = await showConfirm(
|
||||||
|
t('profile.remove_pin_confirm_desc'),
|
||||||
|
t('profile.remove_pin_confirm_title'),
|
||||||
|
t('profile.remove_pin_confirm_yes'),
|
||||||
|
t('profile.remove_pin_confirm_no')
|
||||||
|
)
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
removeLocalPin(username)
|
||||||
|
setPinActive(false)
|
||||||
|
setPinInput('')
|
||||||
|
setPinConfirm('')
|
||||||
|
trackPlausibleEvent(PlausibleEvents.LOCAL_PIN_REMOVED)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRotateRecovery = async () => {
|
||||||
|
const confirmed = await showConfirm(
|
||||||
|
t('profile.recovery_rotate_confirm_desc'),
|
||||||
|
t('profile.recovery_rotate_confirm_title'),
|
||||||
|
t('profile.recovery_rotate_confirm_yes'),
|
||||||
|
t('profile.recovery_rotate_confirm_no')
|
||||||
|
)
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
if (!getActiveMasterKey()) {
|
||||||
|
setError(t('profile.recovery_rotate_no_session'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setRecoveryBusy(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const phrase = await rotateRecoveryPhrase()
|
||||||
|
setPendingRecoveryPhrase(phrase)
|
||||||
|
trackPlausibleEvent(PlausibleEvents.RECOVERY_ROTATED)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof Error && err.message === 'NO_ACTIVE_MASTER_KEY') {
|
||||||
|
setError(t('profile.recovery_rotate_no_session'))
|
||||||
|
} else {
|
||||||
|
setError(err instanceof Error ? err.message : t('profile.recovery_rotate_failed'))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setRecoveryBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopyRecoveryPhrase = async () => {
|
||||||
|
if (!pendingRecoveryPhrase) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(pendingRecoveryPhrase)
|
||||||
|
setRecoveryCopied(true)
|
||||||
|
window.setTimeout(() => setRecoveryCopied(false), 2000)
|
||||||
|
} catch {
|
||||||
|
showAlert(t('profile.copy_failed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirmRecoverySaved = () => {
|
||||||
|
setPendingRecoveryPhrase(null)
|
||||||
|
setRecoveryCopied(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dashboard-container">
|
||||||
|
<header className="dashboard-header dashboard-header--profile">
|
||||||
|
<div className="header-brand profile-header-brand">
|
||||||
|
<button className="btn-back profile-back-btn" onClick={onBack} title={t('profile.back')}>
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
<span>{t('profile.back')}</span>
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<div className="header-brand-title-row">
|
||||||
|
<h1>{t('profile.title')}</h1>
|
||||||
|
<BetaBadge />
|
||||||
|
</div>
|
||||||
|
<p className="subtitle">{t('profile.subtitle', { name: username })}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="header-actions">
|
||||||
|
<button className="btn-icon logout" onClick={onLogout} title={t('dashboard.logout')}>
|
||||||
|
<LogOut size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="profile-main">
|
||||||
|
{error && <div className="auth-error mb-4">{error}</div>}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="tab-placeholder">
|
||||||
|
<User className="header-logo spin" size={48} />
|
||||||
|
<p>{t('profile.loading')}</p>
|
||||||
|
</div>
|
||||||
|
) : pendingRecoveryPhrase ? (
|
||||||
|
<section className="form-card profile-recovery-card">
|
||||||
|
<div className="form-header">
|
||||||
|
<KeyRound size={24} className="form-icon" />
|
||||||
|
<h2>{t('auth.recovery_title')}</h2>
|
||||||
|
</div>
|
||||||
|
<p className="profile-recovery-warning">{t('profile.recovery_rotate_new_warning')}</p>
|
||||||
|
<div className="phrase-grid">
|
||||||
|
{pendingRecoveryPhrase.split(' ').map((word, idx) => (
|
||||||
|
<div key={idx} className="phrase-word">
|
||||||
|
<span className="word-num">{idx + 1}</span>
|
||||||
|
{word}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="form-actions profile-recovery-actions">
|
||||||
|
<button type="button" className="btn secondary" onClick={() => void handleCopyRecoveryPhrase()}>
|
||||||
|
{recoveryCopied ? t('auth.copied') : t('auth.copy_phrase')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn primary" onClick={handleConfirmRecoverySaved}>
|
||||||
|
{t('auth.confirm_recovery')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : profile ? (
|
||||||
|
<>
|
||||||
|
<ProfileAccordionSection
|
||||||
|
id="account"
|
||||||
|
title={t('profile.sections.account')}
|
||||||
|
icon={<User size={20} aria-hidden="true" />}
|
||||||
|
defaultOpen
|
||||||
|
>
|
||||||
|
<div data-tour="profile-preferences">
|
||||||
|
<section className="form-card profile-accordion-inner-card">
|
||||||
|
<div className="form-header">
|
||||||
|
<User size={24} className="form-icon" />
|
||||||
|
<h2>{t('profile.identity_title')}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="profile-dl">
|
||||||
|
<div className="profile-dl-row">
|
||||||
|
<dt>{t('profile.username')}</dt>
|
||||||
|
<dd>{profile.username}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="profile-dl-row">
|
||||||
|
<dt>{t('profile.user_id')}</dt>
|
||||||
|
<dd className="profile-user-id">
|
||||||
|
<code>{profile.userId}</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-icon profile-copy-btn"
|
||||||
|
onClick={() => void handleCopyUserId()}
|
||||||
|
title={t('profile.copy_user_id')}
|
||||||
|
>
|
||||||
|
{copiedUserId ? <Check size={16} /> : <Copy size={16} />}
|
||||||
|
</button>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="profile-dl-row">
|
||||||
|
<dt>{t('profile.account_since')}</dt>
|
||||||
|
<dd>{accountAgeLabel}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="profile-dl-row">
|
||||||
|
<dt>{t('profile.prf_status')}</dt>
|
||||||
|
<dd>
|
||||||
|
{profile.hasPrfEncryption
|
||||||
|
? t('profile.prf_active')
|
||||||
|
: t('profile.prf_inactive')}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<UserProfilePreferences userId={profile.userId} />
|
||||||
|
</div>
|
||||||
|
</ProfileAccordionSection>
|
||||||
|
|
||||||
|
<ProfileAccordionSection
|
||||||
|
id="fleet"
|
||||||
|
title={t('profile.sections.fleet')}
|
||||||
|
icon={<Ship size={20} aria-hidden="true" />}
|
||||||
|
defaultOpen
|
||||||
|
forceOpen={fleetSectionTourOpen ? true : undefined}
|
||||||
|
>
|
||||||
|
<VesselPoolForm />
|
||||||
|
<PersonPoolForm />
|
||||||
|
</ProfileAccordionSection>
|
||||||
|
|
||||||
|
<ProfileAccordionSection
|
||||||
|
id="security"
|
||||||
|
title={t('profile.sections.security')}
|
||||||
|
icon={<Shield size={20} aria-hidden="true" />}
|
||||||
|
>
|
||||||
|
<section className="member-editor-card glass profile-accordion-inner-card">
|
||||||
|
<div className="profile-section-header">
|
||||||
|
<Shield size={20} />
|
||||||
|
<h3>{t('profile.security_title')}</h3>
|
||||||
|
</div>
|
||||||
|
<p className="profile-section-desc">{t('profile.security_desc')}</p>
|
||||||
|
<ul className="profile-security-list">
|
||||||
|
<SecurityCheckItem
|
||||||
|
ok={profile.credentials.length > 0}
|
||||||
|
label={
|
||||||
|
profile.credentials.length > 0
|
||||||
|
? t('profile.security_passkeys_ok')
|
||||||
|
: t('profile.security_passkeys_missing')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SecurityCheckItem
|
||||||
|
ok={profile.hasPrfEncryption}
|
||||||
|
label={
|
||||||
|
profile.hasPrfEncryption
|
||||||
|
? t('profile.security_prf_ok')
|
||||||
|
: t('profile.security_prf_missing')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SecurityCheckItem
|
||||||
|
ok={pinActive}
|
||||||
|
label={pinActive ? t('profile.security_pin_ok') : t('profile.security_pin_missing')}
|
||||||
|
/>
|
||||||
|
<SecurityCheckItem ok label={t('profile.security_recovery_ok')} />
|
||||||
|
</ul>
|
||||||
|
<p className="profile-section-desc profile-recovery-hint">{t('profile.security_recovery_hint')}</p>
|
||||||
|
<div className="form-actions profile-recovery-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={() => void handleRotateRecovery()}
|
||||||
|
disabled={recoveryBusy || passkeyBusy || pinBusy}
|
||||||
|
>
|
||||||
|
{recoveryBusy ? t('profile.processing') : t('profile.recovery_rotate_btn')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="member-editor-card glass">
|
||||||
|
<div className="profile-section-header">
|
||||||
|
<Smartphone size={20} />
|
||||||
|
<h3>{t('profile.device_title')}</h3>
|
||||||
|
</div>
|
||||||
|
<p className="profile-section-desc">{t('profile.device_desc')}</p>
|
||||||
|
<div className={`profile-device-status ${connStatusClassName(online)}`}>
|
||||||
|
{online ? (
|
||||||
|
showSpinner ? (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Wifi size={16} aria-hidden="true" />
|
||||||
|
<span>{t('profile.device_sync_ok')}</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<WifiOff size={16} aria-hidden="true" />
|
||||||
|
<span>{t('sync.status_offline')}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="profile-pin-status">
|
||||||
|
{isKnownDevice ? t('profile.device_remembered') : t('profile.device_not_remembered')}
|
||||||
|
</p>
|
||||||
|
{isKnownDevice && (
|
||||||
|
<div className="form-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={() => void handleForgetDevice()}
|
||||||
|
>
|
||||||
|
{t('profile.device_forget_btn')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="member-editor-card glass">
|
||||||
|
<div className="profile-section-header">
|
||||||
|
<Lock size={20} />
|
||||||
|
<h3>{t('profile.pin_title')}</h3>
|
||||||
|
</div>
|
||||||
|
<p className="profile-section-desc">{t('auth.setup_pin_warning')}</p>
|
||||||
|
<p className="profile-pin-status">
|
||||||
|
{t('profile.pin_status')}:{' '}
|
||||||
|
<strong>{pinActive ? t('profile.pin_active') : t('profile.pin_inactive')}</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={(e) => void handleSavePin(e)} className="profile-pin-form">
|
||||||
|
<div className="input-group">
|
||||||
|
<label htmlFor="profile-pin">{t('auth.pin_label')}</label>
|
||||||
|
<input
|
||||||
|
id="profile-pin"
|
||||||
|
type="password"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="new-password"
|
||||||
|
className="input-text"
|
||||||
|
placeholder={t('auth.pin_placeholder')}
|
||||||
|
value={pinInput}
|
||||||
|
onChange={(e) => setPinInput(e.target.value)}
|
||||||
|
disabled={pinBusy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="input-group">
|
||||||
|
<label htmlFor="profile-pin-confirm">{t('profile.pin_confirm_label')}</label>
|
||||||
|
<input
|
||||||
|
id="profile-pin-confirm"
|
||||||
|
type="password"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="new-password"
|
||||||
|
className="input-text"
|
||||||
|
placeholder={t('profile.pin_confirm_placeholder')}
|
||||||
|
value={pinConfirm}
|
||||||
|
onChange={(e) => setPinConfirm(e.target.value)}
|
||||||
|
disabled={pinBusy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-actions">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn primary"
|
||||||
|
disabled={pinBusy || pinInput.length < 4 || pinConfirm.length < 4}
|
||||||
|
>
|
||||||
|
{pinActive ? t('profile.pin_change_btn') : t('profile.pin_set_btn')}
|
||||||
|
</button>
|
||||||
|
{pinActive && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={() => void handleRemovePin()}
|
||||||
|
disabled={pinBusy}
|
||||||
|
>
|
||||||
|
{t('profile.pin_remove_btn')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="member-editor-card glass">
|
||||||
|
<div className="profile-section-header">
|
||||||
|
<KeyRound size={20} />
|
||||||
|
<h3>{t('profile.passkeys_title')}</h3>
|
||||||
|
</div>
|
||||||
|
<p className="profile-section-desc">{t('profile.passkeys_desc')}</p>
|
||||||
|
|
||||||
|
{profile.credentials.length === 0 ? (
|
||||||
|
<p className="profile-empty">{t('profile.passkeys_empty')}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="profile-passkey-list">
|
||||||
|
{profile.credentials.map((cred) => (
|
||||||
|
<li key={cred.id} className="profile-passkey-item">
|
||||||
|
<div className="profile-passkey-main">
|
||||||
|
<span className="profile-passkey-label">
|
||||||
|
{cred.label || t('profile.passkey_unnamed')}
|
||||||
|
</span>
|
||||||
|
<span className="profile-passkey-id">{cred.credentialIdPreview}</span>
|
||||||
|
{cred.transports.length > 0 && (
|
||||||
|
<span className="profile-passkey-transports">
|
||||||
|
{cred.transports.join(', ')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="profile-passkey-rename">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input-text"
|
||||||
|
value={passkeyLabels[cred.id] ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setPasskeyLabels((prev) => ({ ...prev, [cred.id]: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder={t('profile.passkey_label_placeholder')}
|
||||||
|
disabled={passkeyBusy}
|
||||||
|
maxLength={64}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={() => void handleRenamePasskey(cred.id)}
|
||||||
|
disabled={passkeyBusy}
|
||||||
|
>
|
||||||
|
{t('profile.passkey_rename_btn')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-icon danger"
|
||||||
|
onClick={() => void handleRemovePasskey(cred.id)}
|
||||||
|
disabled={passkeyBusy}
|
||||||
|
title={t('profile.remove_passkey_btn')}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="profile-add-passkey">
|
||||||
|
<div className="input-group">
|
||||||
|
<label htmlFor="profile-new-passkey-label">{t('profile.passkey_label')}</label>
|
||||||
|
<input
|
||||||
|
id="profile-new-passkey-label"
|
||||||
|
type="text"
|
||||||
|
className="input-text"
|
||||||
|
value={newPasskeyLabel}
|
||||||
|
onChange={(e) => setNewPasskeyLabel(e.target.value)}
|
||||||
|
placeholder={t('profile.passkey_label_placeholder')}
|
||||||
|
disabled={passkeyBusy}
|
||||||
|
maxLength={64}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-actions mt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn primary"
|
||||||
|
onClick={() => void handleAddPasskey()}
|
||||||
|
disabled={passkeyBusy}
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
{passkeyBusy ? t('profile.processing') : t('profile.add_passkey_btn')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</ProfileAccordionSection>
|
||||||
|
|
||||||
|
<ProfileAccordionSection
|
||||||
|
id="stats"
|
||||||
|
title={t('profile.sections.stats')}
|
||||||
|
icon={<BarChart2 size={20} aria-hidden="true" />}
|
||||||
|
>
|
||||||
|
<section className="form-card profile-stats-section profile-accordion-inner-card">
|
||||||
|
<div className="form-header">
|
||||||
|
<BarChart2 size={24} className="form-icon" />
|
||||||
|
<div>
|
||||||
|
<h2>{t('profile.stats_title')}</h2>
|
||||||
|
<p className="stats-subtitle">{t('profile.stats_subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(statsTotals || profile) && (
|
||||||
|
<div className="stats-kpi-grid profile-stats-kpi-grid">
|
||||||
|
<KpiCard
|
||||||
|
icon={<BookOpen size={20} />}
|
||||||
|
label={t('profile.stats_logbooks')}
|
||||||
|
value={String(logbookCount)}
|
||||||
|
/>
|
||||||
|
{statsTotals && (
|
||||||
|
<>
|
||||||
|
<KpiCard
|
||||||
|
icon={<Anchor size={20} />}
|
||||||
|
label={t('stats.travel_days')}
|
||||||
|
value={String(statsTotals.travelDayCount)}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={<Gauge size={20} />}
|
||||||
|
label={t('stats.total_distance')}
|
||||||
|
value={formatNm(statsTotals.totalDistanceNm)}
|
||||||
|
unit={t('stats.unit_nm')}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={<Sailboat size={20} />}
|
||||||
|
label={t('stats.sail_distance')}
|
||||||
|
value={formatNm(statsTotals.sailDistanceNm)}
|
||||||
|
unit={t('stats.unit_nm')}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={<Gauge size={20} />}
|
||||||
|
label={t('stats.motor_distance')}
|
||||||
|
value={formatNm(statsTotals.motorDistanceNm)}
|
||||||
|
unit={t('stats.unit_nm')}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={<Timer size={20} />}
|
||||||
|
label={t('stats.motor_hours_total')}
|
||||||
|
value={formatHours(statsTotals.totalMotorHours)}
|
||||||
|
unit={t('stats.unit_h')}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
icon={<Share2 size={20} />}
|
||||||
|
label={t('profile.stats_shared_logbooks')}
|
||||||
|
value={String(sharedLogbookCount)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<KpiCard
|
||||||
|
icon={<Calendar size={20} />}
|
||||||
|
label={t('profile.stats_account_since')}
|
||||||
|
value={accountAgeLabel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</ProfileAccordionSection>
|
||||||
|
|
||||||
|
<ProfileAccordionSection
|
||||||
|
id="danger"
|
||||||
|
title={t('profile.sections.danger')}
|
||||||
|
>
|
||||||
|
<AccountDangerZone className="profile-accordion-inner-card" />
|
||||||
|
</ProfileAccordionSection>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Compass, Palette, Save, Check, Cloud, Brain } 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,
|
||||||
|
getAiAuthorized,
|
||||||
|
setAiAuthorized
|
||||||
|
} 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 [aiAuthorized, setAiAuthorizedState] = useState(() => getAiAuthorized(userId))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleChanged = () => {
|
||||||
|
setTheme(getThemePreference(userId))
|
||||||
|
setColorScheme(getColorSchemePreference(userId))
|
||||||
|
setAiAuthorizedState(getAiAuthorized(userId))
|
||||||
|
}
|
||||||
|
window.addEventListener('appearance-changed', handleChanged)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('appearance-changed', handleChanged)
|
||||||
|
}
|
||||||
|
}, [userId])
|
||||||
|
|
||||||
|
const persistAppearance = (nextTheme: string, nextColorScheme: string) => {
|
||||||
|
setThemePreference(userId, nextTheme)
|
||||||
|
setColorSchemePreference(userId, nextColorScheme)
|
||||||
|
notifyAppearanceChanged()
|
||||||
|
void saveAppearancePrefsToServer(nextTheme, nextColorScheme, aiAuthorized, userId).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)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAiToggle = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const nextVal = e.target.checked
|
||||||
|
setAiAuthorizedState(nextVal)
|
||||||
|
setAiAuthorized(userId, nextVal)
|
||||||
|
void saveAppearancePrefsToServer(theme, colorScheme, nextVal, userId).catch((err) => {
|
||||||
|
console.warn('Failed to save ai preference to server:', err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<section className="member-editor-card glass">
|
||||||
|
<div className="profile-section-header">
|
||||||
|
<Brain size={20} style={{ color: 'var(--app-accent-light)' }} />
|
||||||
|
<h3 style={{ margin: 0, color: 'var(--app-accent-light)', fontSize: '16px' }}>
|
||||||
|
{t('profile.ai_title')}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted" style={{ fontSize: '13.5px', lineHeight: '145%', margin: '0 0 12px 0' }}>
|
||||||
|
{t('profile.ai_desc')}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted" style={{ fontSize: '13px', lineHeight: '145%', margin: '0 0 16px 0', whiteSpace: 'pre-line' }}>
|
||||||
|
{t('profile.ai_help')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label
|
||||||
|
className="switch-label"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#f1f5f9'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="profile-ai-authorize"
|
||||||
|
type="checkbox"
|
||||||
|
checked={aiAuthorized}
|
||||||
|
onChange={handleAiToggle}
|
||||||
|
style={{ width: '18px', height: '18px', cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
<span>{t('profile.ai_enable_label')}</span>
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<PushNotificationSettings />
|
||||||
|
<PwaInstallPrompt variant="inline" />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Ship, Camera, Trash2, Plus, X } from 'lucide-react'
|
||||||
|
import type { VesselFormInputs } from '../utils/vesselFormUtils.js'
|
||||||
|
|
||||||
|
export interface VesselDataFieldsProps {
|
||||||
|
inputs: VesselFormInputs
|
||||||
|
onChange: (next: VesselFormInputs) => void
|
||||||
|
readOnly?: boolean
|
||||||
|
saving?: boolean
|
||||||
|
newSailName: string
|
||||||
|
onNewSailNameChange: (value: string) => void
|
||||||
|
onAddSail: () => void
|
||||||
|
onRemoveSail: (index: number) => void
|
||||||
|
photoError?: string | null
|
||||||
|
onPhotoChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||||
|
onRemovePhoto: () => void
|
||||||
|
fileInputRef: React.RefObject<HTMLInputElement | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VesselDataFields({
|
||||||
|
inputs,
|
||||||
|
onChange,
|
||||||
|
readOnly = false,
|
||||||
|
saving = false,
|
||||||
|
newSailName,
|
||||||
|
onNewSailNameChange,
|
||||||
|
onAddSail,
|
||||||
|
onRemoveSail,
|
||||||
|
photoError,
|
||||||
|
onPhotoChange,
|
||||||
|
onRemovePhoto,
|
||||||
|
fileInputRef
|
||||||
|
}: VesselDataFieldsProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const set = (patch: Partial<VesselFormInputs>) => onChange({ ...inputs, ...patch })
|
||||||
|
|
||||||
|
const triggerFileInput = () => {
|
||||||
|
if (!readOnly) fileInputRef.current?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="form-grid">
|
||||||
|
<div className="vessel-photo-wrapper">
|
||||||
|
<div
|
||||||
|
className="vessel-photo-preview"
|
||||||
|
onClick={triggerFileInput}
|
||||||
|
style={{ cursor: readOnly ? 'default' : 'pointer' }}
|
||||||
|
>
|
||||||
|
{inputs.photo ? (
|
||||||
|
<img src={inputs.photo} alt={inputs.name || 'Vessel'} className="vessel-photo" />
|
||||||
|
) : (
|
||||||
|
<div className="vessel-photo-placeholder">
|
||||||
|
<Ship size={48} className="placeholder-icon" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!readOnly && (
|
||||||
|
<div className="vessel-photo-overlay">
|
||||||
|
<Camera size={24} />
|
||||||
|
<span>{inputs.photo ? t('vessel.photo_change') : t('vessel.photo_add')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!readOnly && (
|
||||||
|
<div className="vessel-photo-actions">
|
||||||
|
<button type="button" className="btn secondary btn-sm" onClick={triggerFileInput} disabled={saving}>
|
||||||
|
<Camera size={16} />
|
||||||
|
{inputs.photo ? t('vessel.photo_change') : t('vessel.photo_add')}
|
||||||
|
</button>
|
||||||
|
{inputs.photo && (
|
||||||
|
<button type="button" className="btn danger btn-sm" onClick={onRemovePhoto} disabled={saving}>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
{t('vessel.photo_delete')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
onChange={onPhotoChange}
|
||||||
|
accept="image/*"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
{photoError && <div className="auth-error mt-2">{photoError}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="input-group">
|
||||||
|
<label>{t('vessel.name')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input-text"
|
||||||
|
value={inputs.name}
|
||||||
|
onChange={(e) => set({ name: e.target.value })}
|
||||||
|
disabled={saving || readOnly}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="input-group">
|
||||||
|
<label>{t('vessel.type')}</label>
|
||||||
|
<select
|
||||||
|
className="input-text"
|
||||||
|
value={inputs.vesselType}
|
||||||
|
onChange={(e) => set({ vesselType: e.target.value })}
|
||||||
|
disabled={saving || readOnly}
|
||||||
|
>
|
||||||
|
<option value="">{t('vessel.type_unset')}</option>
|
||||||
|
<option value="sailing">{t('vessel.type_sailing')}</option>
|
||||||
|
<option value="motor">{t('vessel.type_motor')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="input-group">
|
||||||
|
<label>{t('vessel.length_m')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
className="input-text"
|
||||||
|
value={inputs.lengthM}
|
||||||
|
onChange={(e) => set({ lengthM: e.target.value })}
|
||||||
|
disabled={saving || readOnly}
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="input-group">
|
||||||
|
<label>{t('vessel.draft_m')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
className="input-text"
|
||||||
|
value={inputs.draftM}
|
||||||
|
onChange={(e) => set({ draftM: e.target.value })}
|
||||||
|
disabled={saving || readOnly}
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="input-group">
|
||||||
|
<label>{t('vessel.air_draft_m')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
className="input-text"
|
||||||
|
value={inputs.airDraftM}
|
||||||
|
onChange={(e) => set({ airDraftM: e.target.value })}
|
||||||
|
disabled={saving || readOnly}
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="input-group">
|
||||||
|
<label>{t('vessel.port')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input-text"
|
||||||
|
value={inputs.homePort}
|
||||||
|
onChange={(e) => set({ homePort: e.target.value })}
|
||||||
|
disabled={saving || readOnly}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="input-group">
|
||||||
|
<label>{t('vessel.owner')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input-text"
|
||||||
|
value={inputs.owner}
|
||||||
|
onChange={(e) => set({ owner: e.target.value })}
|
||||||
|
disabled={saving || readOnly}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="input-group">
|
||||||
|
<label>{t('vessel.charter')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input-text"
|
||||||
|
value={inputs.charterCompany}
|
||||||
|
onChange={(e) => set({ charterCompany: e.target.value })}
|
||||||
|
disabled={saving || readOnly}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="input-group">
|
||||||
|
<label>{t('vessel.registration')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input-text"
|
||||||
|
value={inputs.registrationNumber}
|
||||||
|
onChange={(e) => set({ registrationNumber: e.target.value })}
|
||||||
|
disabled={saving || readOnly}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="input-group">
|
||||||
|
<label>{t('vessel.callsign')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input-text"
|
||||||
|
value={inputs.callSign}
|
||||||
|
onChange={(e) => set({ callSign: e.target.value })}
|
||||||
|
disabled={saving || readOnly}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="input-group">
|
||||||
|
<label>{t('vessel.atis')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input-text"
|
||||||
|
value={inputs.atis}
|
||||||
|
onChange={(e) => set({ atis: e.target.value })}
|
||||||
|
disabled={saving || readOnly}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="input-group">
|
||||||
|
<label>{t('vessel.mmsi')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input-text"
|
||||||
|
value={inputs.mmsi}
|
||||||
|
onChange={(e) => set({ mmsi: e.target.value })}
|
||||||
|
disabled={saving || readOnly}
|
||||||
|
/>
|
||||||
|
</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={inputs.freshwaterCapacityL}
|
||||||
|
onChange={(e) => set({ freshwaterCapacityL: 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={inputs.fuelCapacityL}
|
||||||
|
onChange={(e) => set({ fuelCapacityL: 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={inputs.greywaterCapacityL}
|
||||||
|
onChange={(e) => set({ greywaterCapacityL: e.target.value })}
|
||||||
|
disabled={saving || readOnly}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sails-section">
|
||||||
|
<h3>{t('vessel.sails_list')}</h3>
|
||||||
|
<p className="help-text">{t('vessel.sails_help')}</p>
|
||||||
|
<div className="sails-badges-grid">
|
||||||
|
{inputs.sails.length === 0 ? (
|
||||||
|
<span className="no-sails-msg">{t('vessel.no_sails')}</span>
|
||||||
|
) : (
|
||||||
|
inputs.sails.map((sail, idx) => (
|
||||||
|
<span key={idx} className="sail-badge">
|
||||||
|
{sail}
|
||||||
|
{!readOnly && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="remove-btn"
|
||||||
|
onClick={() => onRemoveSail(idx)}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!readOnly && (
|
||||||
|
<div className="add-sail-form">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input-text"
|
||||||
|
placeholder={t('vessel.sail_name_placeholder')}
|
||||||
|
value={newSailName}
|
||||||
|
onChange={(e) => onNewSailNameChange(e.target.value)}
|
||||||
|
disabled={saving}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
onAddSail()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={onAddSail}
|
||||||
|
disabled={saving || !newSailName.trim()}
|
||||||
|
style={{ width: 'auto' }}
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
{t('vessel.add_sail')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,244 @@
|
|||||||
|
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Ship, Plus, Trash2, Edit2, X, Save } from 'lucide-react'
|
||||||
|
import { useDialog } from './ModalDialog.tsx'
|
||||||
|
import VesselDataFields from './VesselDataFields.tsx'
|
||||||
|
import type { VesselFormInputs } from '../utils/vesselFormUtils.js'
|
||||||
|
import { parseVesselFormInputs, vesselDataToFormInputs } from '../utils/vesselFormUtils.js'
|
||||||
|
import { emptyVesselData } from '../types/vessel.js'
|
||||||
|
import { loadVesselPool, saveVessel, deleteVessel, type DecryptedVessel } from '../services/vesselPool.js'
|
||||||
|
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
|
||||||
|
|
||||||
|
export default function VesselPoolForm() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { showConfirm } = useDialog()
|
||||||
|
const [vessels, setVessels] = useState<DecryptedVessel[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [inputs, setInputs] = useState<VesselFormInputs>(vesselDataToFormInputs(emptyVesselData()))
|
||||||
|
const [newSailName, setNewSailName] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [photoError, setPhotoError] = useState<string | null>(null)
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
setVessels(await loadVesselPool())
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void reload()
|
||||||
|
}, [reload])
|
||||||
|
|
||||||
|
const openAdd = () => {
|
||||||
|
setEditingId(null)
|
||||||
|
setInputs(vesselDataToFormInputs(emptyVesselData()))
|
||||||
|
setNewSailName('')
|
||||||
|
setPhotoError(null)
|
||||||
|
setShowForm(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEdit = (vessel: DecryptedVessel) => {
|
||||||
|
setEditingId(vessel.payloadId)
|
||||||
|
setInputs(vesselDataToFormInputs(vessel.data))
|
||||||
|
setNewSailName('')
|
||||||
|
setPhotoError(null)
|
||||||
|
setShowForm(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddSail = () => {
|
||||||
|
const trimmed = newSailName.trim()
|
||||||
|
if (trimmed && !inputs.sails.includes(trimmed)) {
|
||||||
|
setInputs((prev) => ({ ...prev, sails: [...prev.sails, trimmed] }))
|
||||||
|
}
|
||||||
|
setNewSailName('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePhotoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
setPhotoError(null)
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (event) => {
|
||||||
|
const img = new Image()
|
||||||
|
img.onload = () => {
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) throw new Error('Could not get canvas context')
|
||||||
|
let width = img.width
|
||||||
|
let height = img.height
|
||||||
|
const MAX_WIDTH = 800
|
||||||
|
const MAX_HEIGHT = 600
|
||||||
|
if (width > MAX_WIDTH || height > MAX_HEIGHT) {
|
||||||
|
const ratio = Math.min(MAX_WIDTH / width, MAX_HEIGHT / height)
|
||||||
|
width = Math.round(width * ratio)
|
||||||
|
height = Math.round(height * ratio)
|
||||||
|
}
|
||||||
|
canvas.width = width
|
||||||
|
canvas.height = height
|
||||||
|
ctx.drawImage(img, 0, 0, width, height)
|
||||||
|
setInputs((prev) => ({ ...prev, photo: canvas.toDataURL('image/jpeg', 0.7) }))
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setPhotoError(err instanceof Error ? err.message : 'Failed to process image')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
img.onerror = () => setPhotoError('Invalid image file')
|
||||||
|
img.src = event.target?.result as string
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!inputs.name.trim()) return
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const data = parseVesselFormInputs(inputs)
|
||||||
|
const id = editingId ?? window.crypto.randomUUID()
|
||||||
|
await saveVessel(id, data, !editingId)
|
||||||
|
setShowForm(false)
|
||||||
|
trackPlausibleEvent(PlausibleEvents.VESSEL_SAVED, { context: 'vessel_pool' })
|
||||||
|
await reload()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof Error && err.message === 'MAX_VESSELS') {
|
||||||
|
setError(t('vessel_pool.max_vessels'))
|
||||||
|
} else if (err instanceof Error && err.message === 'invalid_metric') {
|
||||||
|
setError(t('vessel.invalid_metric'))
|
||||||
|
} else if (err instanceof Error && err.message === 'invalid_tank_liters') {
|
||||||
|
setError(t('vessel.invalid_tank_liters'))
|
||||||
|
} else {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to save')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (
|
||||||
|
!(await showConfirm(
|
||||||
|
t('vessel_pool.delete_confirm'),
|
||||||
|
t('vessel_pool.title'),
|
||||||
|
t('logs.confirm_yes'),
|
||||||
|
t('logs.confirm_no')
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await deleteVessel(id)
|
||||||
|
await reload()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to delete')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="tab-placeholder">
|
||||||
|
<Ship className="header-logo spin" size={48} />
|
||||||
|
<p>{t('vessel_pool.loading')}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-tour="profile-vessel-pool">
|
||||||
|
<div className="section-title-bar mb-4">
|
||||||
|
<h3>{t('vessel_pool.section_title')}</h3>
|
||||||
|
{!showForm && (
|
||||||
|
<button type="button" className="btn primary" style={{ width: 'auto', padding: '8px 16px' }} onClick={openAdd}>
|
||||||
|
<Plus size={16} />
|
||||||
|
{t('vessel_pool.add_vessel')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="help-text mb-4">{t('vessel_pool.subtitle')}</p>
|
||||||
|
{error && <div className="auth-error mb-4">{error}</div>}
|
||||||
|
|
||||||
|
{vessels.length === 0 ? (
|
||||||
|
<p className="help-text mb-4">{t('vessel_pool.no_vessels')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="crew-grid mb-6">
|
||||||
|
{vessels.map((v) => (
|
||||||
|
<div key={v.payloadId} className="crew-member-card glass">
|
||||||
|
<div className="crew-card-header">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
{v.data.photo ? (
|
||||||
|
<img src={v.data.photo} alt="" className="crew-card-avatar" />
|
||||||
|
) : (
|
||||||
|
<div className="crew-card-avatar-placeholder">
|
||||||
|
<Ship size={18} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<h4>{v.data.name}</h4>
|
||||||
|
{v.data.homePort && <p className="help-text">{v.data.homePort}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card-actions">
|
||||||
|
<button type="button" className="btn-icon" onClick={() => openEdit(v)}>
|
||||||
|
<Edit2 size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-icon danger"
|
||||||
|
onClick={() => void handleDelete(v.payloadId)}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<form onSubmit={(e) => void handleSave(e)} className="member-editor-card glass">
|
||||||
|
<div className="editor-header mb-4">
|
||||||
|
<h3>{editingId ? t('vessel_pool.edit_vessel') : t('vessel_pool.add_vessel')}</h3>
|
||||||
|
<button type="button" className="btn-icon" onClick={() => setShowForm(false)}>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<VesselDataFields
|
||||||
|
inputs={inputs}
|
||||||
|
onChange={setInputs}
|
||||||
|
saving={saving}
|
||||||
|
newSailName={newSailName}
|
||||||
|
onNewSailNameChange={setNewSailName}
|
||||||
|
onAddSail={handleAddSail}
|
||||||
|
onRemoveSail={(idx) =>
|
||||||
|
setInputs((prev) => ({ ...prev, sails: prev.sails.filter((_, i) => i !== idx) }))
|
||||||
|
}
|
||||||
|
photoError={photoError}
|
||||||
|
onPhotoChange={handlePhotoChange}
|
||||||
|
onRemovePhoto={() => {
|
||||||
|
setInputs((prev) => ({ ...prev, photo: null }))
|
||||||
|
if (fileRef.current) fileRef.current.value = ''
|
||||||
|
}}
|
||||||
|
fileInputRef={fileRef}
|
||||||
|
/>
|
||||||
|
<div className="form-actions mt-4">
|
||||||
|
<button type="submit" className="btn primary" disabled={saving || !inputs.name.trim()}>
|
||||||
|
<Save size={18} />
|
||||||
|
{saving ? t('vessel.saving') : t('vessel.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { useEffect, useState, useRef } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { db } from '../services/db.js'
|
||||||
|
import { getActiveMasterKey } from '../services/auth.js'
|
||||||
|
import { getLogbookKey } from '../services/logbookKeys.js'
|
||||||
|
import { decryptJson } from '../services/crypto.js'
|
||||||
|
|
||||||
|
export interface PreloadedVoiceMemo {
|
||||||
|
payloadId: string
|
||||||
|
audio: string
|
||||||
|
mimeType?: string
|
||||||
|
durationSec?: number
|
||||||
|
caption?: string
|
||||||
|
transcribed?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VoiceMemoPlayerProps {
|
||||||
|
audioId: string
|
||||||
|
logbookId: string
|
||||||
|
preloaded?: PreloadedVoiceMemo | null
|
||||||
|
compact?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VoiceMemoPlayer({
|
||||||
|
audioId,
|
||||||
|
logbookId,
|
||||||
|
preloaded,
|
||||||
|
compact = false
|
||||||
|
}: VoiceMemoPlayerProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [src, setSrc] = useState<string | null>(preloaded?.audio ?? null)
|
||||||
|
const [error, setError] = useState(false)
|
||||||
|
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = audioRef.current
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
const handleLoadedMetadata = () => {
|
||||||
|
if (el.duration === Infinity || isNaN(el.duration) || el.duration === 0) {
|
||||||
|
el.currentTime = 1e10
|
||||||
|
const onTimeUpdate = () => {
|
||||||
|
el.currentTime = 0
|
||||||
|
el.removeEventListener('timeupdate', onTimeUpdate)
|
||||||
|
}
|
||||||
|
el.addEventListener('timeupdate', onTimeUpdate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (el.readyState >= 1) {
|
||||||
|
handleLoadedMetadata()
|
||||||
|
} else {
|
||||||
|
el.addEventListener('loadedmetadata', handleLoadedMetadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (src) {
|
||||||
|
el.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener('loadedmetadata', handleLoadedMetadata)
|
||||||
|
}
|
||||||
|
}, [src])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (preloaded?.audio) {
|
||||||
|
setSrc(preloaded.audio)
|
||||||
|
setError(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const record = await db.voiceMemos.get(audioId)
|
||||||
|
if (!record || cancelled) return
|
||||||
|
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||||
|
if (!masterKey || cancelled) return
|
||||||
|
const decrypted = await decryptJson(record.encryptedData, record.iv, record.tag, masterKey)
|
||||||
|
if (!decrypted?.audio || cancelled) {
|
||||||
|
setError(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSrc(String(decrypted.audio))
|
||||||
|
setError(false)
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setError(true)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [audioId, logbookId, preloaded?.audio])
|
||||||
|
|
||||||
|
if (error || !src) {
|
||||||
|
return (
|
||||||
|
<span className="voice-memo-player-unavailable">
|
||||||
|
{t('logs.live_voice_unavailable')}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const playerClass = compact
|
||||||
|
? 'voice-memo-player voice-memo-player--compact'
|
||||||
|
: 'voice-memo-player'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="voice-memo-player-shell">
|
||||||
|
<audio ref={audioRef} className={playerClass} controls preload="metadata" src={src} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
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).toContain('profile_vessel_pool')
|
||||||
|
expect(FULL_STEP_ORDER).toContain('profile_crew_pool')
|
||||||
|
expect(FULL_STEP_ORDER).toContain('nav_logbook_crew')
|
||||||
|
expect(FULL_STEP_ORDER.indexOf('profile_vessel_pool')).toBeLessThan(
|
||||||
|
FULL_STEP_ORDER.indexOf('profile_crew_pool')
|
||||||
|
)
|
||||||
|
expect(FULL_STEP_ORDER).toHaveLength(14)
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -26,15 +26,22 @@ export type TourStepId =
|
|||||||
| 'entry_open'
|
| 'entry_open'
|
||||||
| 'entry_track'
|
| 'entry_track'
|
||||||
| 'nav_vessel'
|
| 'nav_vessel'
|
||||||
| 'nav_crew'
|
| 'profile_vessel_pool'
|
||||||
|
| 'profile_crew_pool'
|
||||||
|
| 'nav_logbook_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 +54,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,22 +66,46 @@ 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',
|
||||||
'entry_open',
|
'entry_open',
|
||||||
'entry_track',
|
'entry_track',
|
||||||
'nav_vessel',
|
'nav_vessel',
|
||||||
'nav_crew',
|
'profile_vessel_pool',
|
||||||
|
'profile_crew_pool',
|
||||||
|
'nav_logbook_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))
|
'profile_crew_pool',
|
||||||
|
'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_logbook_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
|
||||||
@@ -85,9 +117,39 @@ const TARGET_BY_STEP: Partial<Record<TourStepId, string>> = {
|
|||||||
entry_open: '[data-tour="entry-first"]',
|
entry_open: '[data-tour="entry-first"]',
|
||||||
entry_track: '[data-tour="entry-track"]',
|
entry_track: '[data-tour="entry-track"]',
|
||||||
nav_vessel: '[data-tour="nav-vessel"]',
|
nav_vessel: '[data-tour="nav-vessel"]',
|
||||||
nav_crew: '[data-tour="nav-crew"]',
|
profile_vessel_pool: '[data-tour="profile-vessel-pool"]',
|
||||||
|
profile_crew_pool: '[data-tour="profile-crew-pool"]',
|
||||||
|
nav_logbook_crew: '[data-tour="nav-logbook-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' ||
|
||||||
|
stepId === 'profile_vessel_pool' ||
|
||||||
|
stepId === 'profile_crew_pool'
|
||||||
|
) {
|
||||||
|
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 +159,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,19 +175,37 @@ 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')
|
||||||
}
|
}
|
||||||
if (stepId === 'nav_crew') {
|
if (stepId === 'profile_vessel_pool' || stepId === 'profile_crew_pool') {
|
||||||
nav.setSelectedEntryId(null)
|
nav.setSelectedEntryId(null)
|
||||||
|
nav.setLogbookActive(false)
|
||||||
|
nav.setProfileOpen(true)
|
||||||
|
}
|
||||||
|
if (stepId === 'nav_logbook_crew') {
|
||||||
|
nav.setSelectedEntryId(null)
|
||||||
|
nav.setProfileOpen(false)
|
||||||
|
nav.setLogbookActive(true)
|
||||||
nav.setActiveTab('crew')
|
nav.setActiveTab('crew')
|
||||||
}
|
}
|
||||||
if (stepId === 'nav_stats') {
|
if (stepId === 'nav_stats') {
|
||||||
@@ -137,19 +218,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 +269,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 +281,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 +312,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 +373,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 +398,7 @@ export function AppTourProvider({ children }: { children: ReactNode }) {
|
|||||||
startTour,
|
startTour,
|
||||||
stepIndex,
|
stepIndex,
|
||||||
stepOrder.length,
|
stepOrder.length,
|
||||||
|
layoutTick,
|
||||||
stopTour
|
stopTour
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -321,3 +439,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
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useDialog } from '../components/ModalDialog.tsx'
|
|||||||
|
|
||||||
interface UnsavedChangesContextValue {
|
interface UnsavedChangesContextValue {
|
||||||
setDirty: (source: string, dirty: boolean) => void
|
setDirty: (source: string, dirty: boolean) => void
|
||||||
|
registerSaveHandler: (source: string, handler: (() => Promise<void>) | null) => void
|
||||||
confirmLeave: () => Promise<boolean>
|
confirmLeave: () => Promise<boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,23 +20,51 @@ const UnsavedChangesContext = createContext<UnsavedChangesContextValue | null>(n
|
|||||||
|
|
||||||
export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
|
export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { showConfirm } = useDialog()
|
const { showConfirmLeave, showAlert } = useDialog()
|
||||||
const dirtySources = useRef(new Set<string>())
|
const dirtySources = useRef(new Set<string>())
|
||||||
|
const saveHandlers = useRef(new Map<string, () => Promise<void>>())
|
||||||
|
|
||||||
const setDirty = useCallback((source: string, dirty: boolean) => {
|
const setDirty = useCallback((source: string, dirty: boolean) => {
|
||||||
if (dirty) dirtySources.current.add(source)
|
if (dirty) dirtySources.current.add(source)
|
||||||
else dirtySources.current.delete(source)
|
else dirtySources.current.delete(source)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const registerSaveHandler = useCallback((source: string, handler: (() => Promise<void>) | null) => {
|
||||||
|
if (handler) saveHandlers.current.set(source, handler)
|
||||||
|
else saveHandlers.current.delete(source)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const confirmLeave = useCallback(async (): Promise<boolean> => {
|
const confirmLeave = useCallback(async (): Promise<boolean> => {
|
||||||
if (dirtySources.current.size === 0) return true
|
if (dirtySources.current.size === 0) return true
|
||||||
return showConfirm(
|
|
||||||
|
const canSave = [...dirtySources.current].some((source) => saveHandlers.current.has(source))
|
||||||
|
const choice = await showConfirmLeave(
|
||||||
t('common.unsaved_changes_message'),
|
t('common.unsaved_changes_message'),
|
||||||
t('common.unsaved_changes_title'),
|
t('common.unsaved_changes_title'),
|
||||||
t('common.unsaved_changes_leave'),
|
t('common.unsaved_changes_stay'),
|
||||||
t('common.unsaved_changes_stay')
|
t('common.unsaved_changes_save_leave'),
|
||||||
|
t('common.unsaved_changes_discard'),
|
||||||
|
{ showSave: canSave }
|
||||||
)
|
)
|
||||||
}, [showConfirm, t])
|
|
||||||
|
if (choice === 'stay') return false
|
||||||
|
if (choice === 'discard') return true
|
||||||
|
|
||||||
|
const handlers = [...dirtySources.current]
|
||||||
|
.map((source) => saveHandlers.current.get(source))
|
||||||
|
.filter((handler): handler is () => Promise<void> => handler != null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const handler of handlers) {
|
||||||
|
await handler()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save before leaving:', err)
|
||||||
|
await showAlert(t('errors.save_failed'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, [showConfirmLeave, showAlert, t])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: BeforeUnloadEvent) => {
|
const handler = (e: BeforeUnloadEvent) => {
|
||||||
@@ -47,7 +76,10 @@ export function UnsavedChangesProvider({ children }: { children: ReactNode }) {
|
|||||||
return () => window.removeEventListener('beforeunload', handler)
|
return () => window.removeEventListener('beforeunload', handler)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const value = useMemo(() => ({ setDirty, confirmLeave }), [setDirty, confirmLeave])
|
const value = useMemo(
|
||||||
|
() => ({ setDirty, registerSaveHandler, confirmLeave }),
|
||||||
|
[setDirty, registerSaveHandler, confirmLeave]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UnsavedChangesContext.Provider value={value}>
|
<UnsavedChangesContext.Provider value={value}>
|
||||||
@@ -65,13 +97,26 @@ export function useUnsavedChangesContext(): UnsavedChangesContextValue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Register a form/view as having unsaved changes (cleared automatically on unmount). */
|
/** Register a form/view as having unsaved changes (cleared automatically on unmount). */
|
||||||
export function useRegisterUnsavedChanges(source: string, isDirty: boolean) {
|
export function useRegisterUnsavedChanges(
|
||||||
const { setDirty, confirmLeave } = useUnsavedChangesContext()
|
source: string,
|
||||||
|
isDirty: boolean,
|
||||||
|
onSave?: () => Promise<void>
|
||||||
|
) {
|
||||||
|
const { setDirty, registerSaveHandler, confirmLeave } = useUnsavedChangesContext()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDirty(source, isDirty)
|
setDirty(source, isDirty)
|
||||||
return () => setDirty(source, false)
|
return () => setDirty(source, false)
|
||||||
}, [source, isDirty, setDirty])
|
}, [source, isDirty, setDirty])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!onSave) {
|
||||||
|
registerSaveHandler(source, null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
registerSaveHandler(source, onSave)
|
||||||
|
return () => registerSaveHandler(source, null)
|
||||||
|
}, [source, onSave, registerSaveHandler])
|
||||||
|
|
||||||
return { confirmLeave }
|
return { confirmLeave }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useLiveQuery } from 'dexie-react-hooks'
|
||||||
|
import { db } from '../services/db.js'
|
||||||
|
import { getActiveMasterKey } from '../services/auth.js'
|
||||||
|
import { getLogbookKey } from '../services/logbookKeys.js'
|
||||||
|
import { decryptJson } from '../services/crypto.js'
|
||||||
|
import type { PreloadedVoiceMemo } from '../components/VoiceMemoPlayer.tsx'
|
||||||
|
|
||||||
|
export function useEntryVoiceMemos(
|
||||||
|
logbookId: string,
|
||||||
|
entryId: string | null,
|
||||||
|
preloaded?: PreloadedVoiceMemo[]
|
||||||
|
): Map<string, PreloadedVoiceMemo> {
|
||||||
|
const localMemos = useLiveQuery(
|
||||||
|
() => (entryId ? db.voiceMemos.where({ entryId }).toArray() : []),
|
||||||
|
[entryId]
|
||||||
|
)
|
||||||
|
|
||||||
|
const [lookup, setLookup] = useState<Map<string, PreloadedVoiceMemo>>(new Map())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (preloaded && preloaded.length > 0) {
|
||||||
|
const map = new Map<string, PreloadedVoiceMemo>()
|
||||||
|
for (const m of preloaded) {
|
||||||
|
map.set(m.payloadId, m)
|
||||||
|
}
|
||||||
|
setLookup(map)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entryId || !localMemos) {
|
||||||
|
setLookup(new Map())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
void (async () => {
|
||||||
|
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
|
||||||
|
if (!masterKey || cancelled) return
|
||||||
|
|
||||||
|
const map = new Map<string, PreloadedVoiceMemo>()
|
||||||
|
for (const row of localMemos) {
|
||||||
|
try {
|
||||||
|
const decrypted = await decryptJson(row.encryptedData, row.iv, row.tag, masterKey)
|
||||||
|
if (!decrypted?.audio) continue
|
||||||
|
map.set(row.payloadId, {
|
||||||
|
payloadId: row.payloadId,
|
||||||
|
audio: String(decrypted.audio),
|
||||||
|
mimeType: decrypted.mimeType ? String(decrypted.mimeType) : undefined,
|
||||||
|
durationSec: typeof decrypted.durationSec === 'number' ? decrypted.durationSec : undefined,
|
||||||
|
caption: decrypted.caption ? String(decrypted.caption) : '',
|
||||||
|
transcribed: decrypted.transcribed !== false
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// skip corrupt memo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!cancelled) setLookup(map)
|
||||||
|
})()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [localMemos, entryId, logbookId, preloaded])
|
||||||
|
|
||||||
|
return lookup
|
||||||
|
}
|
||||||
@@ -1,11 +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
|
||||||
|
const UPDATE_HARD_RECOVERY_MS = 5_000
|
||||||
|
|
||||||
function isUpdateSuppressed(): boolean {
|
function isUpdateSuppressed(): boolean {
|
||||||
const suppressUntil = Number(sessionStorage.getItem(UPDATE_SUPPRESS_KEY) || '0')
|
const suppressUntil = Number(sessionStorage.getItem(UPDATE_SUPPRESS_KEY) || '0')
|
||||||
@@ -20,64 +28,121 @@ 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 = () => {
|
||||||
if (document.visibilityState === 'visible') {
|
if (document.visibilityState === 'visible') {
|
||||||
checkForUpdate()
|
// Delay check on wake-up to allow the mobile network stack to stabilize
|
||||||
|
setTimeout(checkForUpdate, 2000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onOnline = () => {
|
||||||
|
// Small delay to ensure connection is fully established
|
||||||
|
setTimeout(checkForUpdate, 500)
|
||||||
|
}
|
||||||
|
|
||||||
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],
|
||||||
updateServiceWorker
|
updateServiceWorker
|
||||||
} = useRegisterSW({
|
} = useRegisterSW({
|
||||||
immediate: true,
|
immediate: !import.meta.env.DEV,
|
||||||
onNeedReload() {
|
onNeedReload() {
|
||||||
clearUpdateSuppression()
|
if (isUpdateSuppressed()) return
|
||||||
setNeedRefresh(false)
|
applyNeedRefresh(true)
|
||||||
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])
|
||||||
|
|
||||||
@@ -86,11 +151,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import deJson from './locales/de.json'
|
||||||
|
import enJson from './locales/en.json'
|
||||||
|
|
||||||
|
const resources = {
|
||||||
|
de: { translation: deJson.translation },
|
||||||
|
en: { translation: enJson.translation }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('course dial i18n keys', () => {
|
||||||
|
it.each([
|
||||||
|
'logs.event_course_section',
|
||||||
|
'logs.course_tab_mgk',
|
||||||
|
'logs.course_tab_rwk',
|
||||||
|
'logs.course_dial_hint',
|
||||||
|
'logs.course_step_fine',
|
||||||
|
'logs.wind_mode_cardinal'
|
||||||
|
])('resolves %s in de and en bundles', async (key) => {
|
||||||
|
const { default: i18n } = await import('i18next')
|
||||||
|
await i18n.init({ lng: 'de', resources, defaultNS: 'translation' })
|
||||||
|
expect(i18n.t(key)).not.toBe(key)
|
||||||
|
await i18n.changeLanguage('en')
|
||||||
|
expect(i18n.t(key)).not.toBe(key)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,25 +1,47 @@
|
|||||||
import i18n from 'i18next'
|
import i18n from 'i18next'
|
||||||
import { initReactI18next } from 'react-i18next'
|
import { initReactI18next } from 'react-i18next'
|
||||||
import LanguageDetector from 'i18next-browser-languagedetector'
|
import LanguageDetector from 'i18next-browser-languagedetector'
|
||||||
import enTranslation from './locales/en.json'
|
import enJson from './locales/en.json'
|
||||||
import deTranslation from './locales/de.json'
|
import deJson from './locales/de.json'
|
||||||
|
import daJson from './locales/da.json'
|
||||||
|
import svJson from './locales/sv.json'
|
||||||
|
import nbJson from './locales/nb.json'
|
||||||
|
import frJson from './locales/fr.json'
|
||||||
|
import esJson from './locales/es.json'
|
||||||
|
import { initSeo } from '../utils/seo.js'
|
||||||
|
import { SUPPORTED_LANGUAGES } from '../utils/i18nLanguages.js'
|
||||||
|
|
||||||
|
/** JSON files wrap strings in `translation` — register that namespace explicitly. */
|
||||||
|
const resources = {
|
||||||
|
en: { translation: enJson.translation },
|
||||||
|
de: { translation: deJson.translation },
|
||||||
|
da: { translation: daJson.translation },
|
||||||
|
sv: { translation: svJson.translation },
|
||||||
|
nb: { translation: nbJson.translation },
|
||||||
|
fr: { translation: frJson.translation },
|
||||||
|
es: { translation: esJson.translation }
|
||||||
|
}
|
||||||
|
|
||||||
i18n
|
i18n
|
||||||
.use(LanguageDetector)
|
.use(LanguageDetector)
|
||||||
.use(initReactI18next)
|
.use(initReactI18next)
|
||||||
.init({
|
.init({
|
||||||
resources: {
|
resources,
|
||||||
en: enTranslation,
|
defaultNS: 'translation',
|
||||||
de: deTranslation
|
|
||||||
},
|
|
||||||
fallbackLng: 'en',
|
fallbackLng: 'en',
|
||||||
|
supportedLngs: [...SUPPORTED_LANGUAGES],
|
||||||
|
nonExplicitSupportedLngs: true,
|
||||||
|
load: 'languageOnly',
|
||||||
interpolation: {
|
interpolation: {
|
||||||
escapeValue: false // React already escapes values (prevents XSS)
|
escapeValue: false // React already escapes values (prevents XSS)
|
||||||
},
|
},
|
||||||
detection: {
|
detection: {
|
||||||
order: ['localStorage', 'navigator'],
|
order: ['querystring', 'localStorage', 'navigator'],
|
||||||
|
lookupQuerystring: 'lng',
|
||||||
caches: ['localStorage']
|
caches: ['localStorage']
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
initSeo(i18n)
|
||||||
|
|
||||||
export default i18n
|
export default i18n
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import deJson from '../i18n/locales/de.json'
|
||||||
|
import enJson from '../i18n/locales/en.json'
|
||||||
|
import daJson from '../i18n/locales/da.json'
|
||||||
|
import svJson from '../i18n/locales/sv.json'
|
||||||
|
import nbJson from '../i18n/locales/nb.json'
|
||||||
|
import frJson from '../i18n/locales/fr.json'
|
||||||
|
import esJson from '../i18n/locales/es.json'
|
||||||
|
|
||||||
|
function collectKeys(obj: Record<string, unknown>, prefix = ''): string[] {
|
||||||
|
const keys: string[] = []
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
const path = prefix ? `${prefix}.${key}` : key
|
||||||
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
|
keys.push(...collectKeys(value as Record<string, unknown>, path))
|
||||||
|
} else {
|
||||||
|
keys.push(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keys.sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
const bundles = {
|
||||||
|
de: deJson.translation,
|
||||||
|
en: enJson.translation,
|
||||||
|
da: daJson.translation,
|
||||||
|
sv: svJson.translation,
|
||||||
|
nb: nbJson.translation,
|
||||||
|
fr: frJson.translation,
|
||||||
|
es: esJson.translation
|
||||||
|
} as const
|
||||||
|
|
||||||
|
describe('i18n locale key parity', () => {
|
||||||
|
const masterKeys = collectKeys(bundles.de)
|
||||||
|
|
||||||
|
it.each(Object.keys(bundles).filter((lang) => lang !== 'de'))(
|
||||||
|
'%s has the same keys as de',
|
||||||
|
(lang) => {
|
||||||
|
const keys = collectKeys(bundles[lang as keyof typeof bundles])
|
||||||
|
expect(keys).toEqual(masterKeys)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
File diff suppressed because it is too large
Load Diff
+575
-57
@@ -6,24 +6,51 @@
|
|||||||
"beta": "Beta",
|
"beta": "Beta",
|
||||||
"beta_hint": "Beta-Version — Funktionen können sich noch ändern"
|
"beta_hint": "Beta-Version — Funktionen können sich noch ändern"
|
||||||
},
|
},
|
||||||
|
"footer": {
|
||||||
|
"kofi_label": "Ko-fi",
|
||||||
|
"kofi_title": "Projekt, Weiterentwicklung und Betriebskosten auf Ko-fi unterstützen"
|
||||||
|
},
|
||||||
|
"languages": {
|
||||||
|
"de": "Deutsch",
|
||||||
|
"en": "English",
|
||||||
|
"da": "Dansk",
|
||||||
|
"sv": "Svenska",
|
||||||
|
"nb": "Norsk",
|
||||||
|
"fr": "Français",
|
||||||
|
"es": "Español"
|
||||||
|
},
|
||||||
|
"dialog": {
|
||||||
|
"ok": "OK",
|
||||||
|
"yes": "Ja",
|
||||||
|
"no": "Nein"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"load_failed": "Daten konnten nicht geladen werden.",
|
||||||
|
"save_failed": "Änderungen konnten nicht gespeichert werden.",
|
||||||
|
"delete_failed": "Löschen fehlgeschlagen.",
|
||||||
|
"export_failed": "Export fehlgeschlagen."
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"unsaved_changes_title": "Ungespeicherte Änderungen",
|
"unsaved_changes_title": "Ungespeicherte Änderungen",
|
||||||
"unsaved_changes_message": "Du hast ungespeicherte Änderungen. Möchtest du die Seite wirklich verlassen? Deine Änderungen gehen verloren.",
|
"unsaved_changes_message": "Du hast ungespeicherte Änderungen. Möchtest du die Seite wirklich verlassen? Deine Änderungen gehen verloren.",
|
||||||
"unsaved_changes_leave": "Verlassen",
|
"unsaved_changes_stay": "Bleiben",
|
||||||
"unsaved_changes_stay": "Bleiben"
|
"unsaved_changes_save_leave": "Speichern & verlassen",
|
||||||
|
"unsaved_changes_discard": "Verwerfen",
|
||||||
|
"unsaved_changes_leave": "Verlassen"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"vessel": "Schiffsdaten",
|
"vessel": "Schiffsdaten",
|
||||||
"crew": "Crew-Liste",
|
"crew": "Crew",
|
||||||
"deviation": "Ablenkungstabelle",
|
"deviation": "Ablenkungstabelle",
|
||||||
"logs": "Logbucheinträge",
|
"logs": "Logbucheinträge",
|
||||||
"stats": "Statistik",
|
"stats": "Statistik",
|
||||||
"settings": "Einstellungen"
|
"settings": "Einstellungen",
|
||||||
|
"admin": "Admin"
|
||||||
},
|
},
|
||||||
"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}}",
|
||||||
@@ -61,7 +88,20 @@
|
|||||||
"enter_pin_placeholder": "Gib deine PIN ein...",
|
"enter_pin_placeholder": "Gib deine PIN ein...",
|
||||||
"decrypt_with_pin": "Entschlüsseln",
|
"decrypt_with_pin": "Entschlüsseln",
|
||||||
"use_recovery_instead": "Stattdessen Wiederherstellungsschlüssel verwenden",
|
"use_recovery_instead": "Stattdessen Wiederherstellungsschlüssel verwenden",
|
||||||
"error_incorrect_pin": "Falsche PIN. Entschlüsselung fehlgeschlagen."
|
"error_incorrect_pin": "Falsche PIN. Entschlüsselung fehlgeschlagen.",
|
||||||
|
"error_invalid_host": "Passkeys funktionieren nicht über 127.0.0.1. Bitte die App über localhost öffnen.",
|
||||||
|
"use_localhost_link": "Zu localhost wechseln",
|
||||||
|
"error_passkey_cancelled": "Passkey-Anmeldung abgebrochen oder abgelaufen. Bitte erneut versuchen.",
|
||||||
|
"error_invalid_rp_id": "Passkey-Domain passt nicht (RP ID). Lokal nur http://localhost:5173 mit RP_ID=localhost in .env verwenden.",
|
||||||
|
"error_session_incomplete": "Anmeldung unvollständig. Bitte erneut mit Passkey anmelden.",
|
||||||
|
"restore_checking": "Session wird geprüft…",
|
||||||
|
"restore_title": "Session wiederherstellen",
|
||||||
|
"restore_subtitle": "Deine Anmeldung ist noch aktiv. Entsperre dein Logbuch mit Passkey oder PIN.",
|
||||||
|
"restore_unlocking": "Wird entsperrt…",
|
||||||
|
"restore_with_passkey": "Mit Passkey entsperren ({{name}})",
|
||||||
|
"restore_with_pin": "Mit PIN entsperren",
|
||||||
|
"restore_pin_warning": "Gib deine lokale PIN ein, um dein Logbuch nach dem Neuladen zu entsperren.",
|
||||||
|
"restore_other_account": "Anderer Account anmelden"
|
||||||
},
|
},
|
||||||
"pwa": {
|
"pwa": {
|
||||||
"title": "App installieren",
|
"title": "App installieren",
|
||||||
@@ -80,12 +120,18 @@
|
|||||||
"update_title": "Update verfügbar",
|
"update_title": "Update verfügbar",
|
||||||
"update_desc": "Eine neue Version von Kapteins Daagbok ist bereit. Bitte aktualisieren, um die neuesten Änderungen zu erhalten.",
|
"update_desc": "Eine neue Version von Kapteins Daagbok ist bereit. Bitte aktualisieren, um die neuesten Änderungen zu erhalten.",
|
||||||
"update_now": "Jetzt aktualisieren",
|
"update_now": "Jetzt aktualisieren",
|
||||||
"update_reloading": "Wird geladen…"
|
"update_reloading": "Wird geladen…",
|
||||||
|
"storage_persist_hint": "Der Browser kann Offline-Daten löschen. Erlaube dauerhafte Speicherung, damit dein Logbuch geschützt bleibt (in den Browser-Einstellungen oder beim nächsten Hinweis)."
|
||||||
},
|
},
|
||||||
"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",
|
||||||
|
"conflict_title": "Synchronisationskonflikt",
|
||||||
|
"conflict_message": "{{count}} Änderung(en) konnten nicht synchronisiert werden (Eintrag {{id}}…). Bitte wähle, welche Version gelten soll.",
|
||||||
|
"conflict_use_server": "Server-Version übernehmen",
|
||||||
|
"conflict_keep_local": "Meine Version behalten"
|
||||||
},
|
},
|
||||||
"vessel": {
|
"vessel": {
|
||||||
"title": "Schiffs-Stammdaten",
|
"title": "Schiffs-Stammdaten",
|
||||||
@@ -116,7 +162,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",
|
||||||
@@ -131,12 +183,20 @@
|
|||||||
"sign_cleared_skipper_re_sign_title": "Skipper-Unterschrift entfernt",
|
"sign_cleared_skipper_re_sign_title": "Skipper-Unterschrift entfernt",
|
||||||
"sign_cleared_skipper_re_sign": "Das Ereignisprotokoll wurde geändert. Die Skipper-Unterschrift wurde entfernt. Bitte erneut freigeben.",
|
"sign_cleared_skipper_re_sign": "Das Ereignisprotokoll wurde geändert. Die Skipper-Unterschrift wurde entfernt. Bitte erneut freigeben.",
|
||||||
"date": "Datum",
|
"date": "Datum",
|
||||||
"day_of_travel": "Tag der Reise / Reisetag",
|
"day_of_travel": "Reisetag",
|
||||||
|
"travel_day_number": "Reisetag {{number}}",
|
||||||
"departure": "Start-Hafen (Reise von)",
|
"departure": "Start-Hafen (Reise von)",
|
||||||
"destination": "Ziel-Hafen (nach)",
|
"destination": "Ziel-Hafen (nach)",
|
||||||
"route": "Reise von/nach",
|
"route": "Reise von/nach",
|
||||||
|
"tanks": "Tanks",
|
||||||
|
"customize_columns": "Spalten anpassen",
|
||||||
|
"column_selector_title": "Anzuzeigende Spalten",
|
||||||
"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",
|
||||||
@@ -179,20 +239,159 @@
|
|||||||
"saving": "Wird gespeichert...",
|
"saving": "Wird gespeichert...",
|
||||||
"saved": "Logbuchseite erfolgreich gespeichert!",
|
"saved": "Logbuchseite erfolgreich gespeichert!",
|
||||||
"loading": "Journal wird geladen...",
|
"loading": "Journal wird geladen...",
|
||||||
|
"view_mode_label": "Ansicht",
|
||||||
|
"view_list": "Liste",
|
||||||
|
"live_mode": "Live",
|
||||||
|
"live_title": "Live-Journal",
|
||||||
|
"live_loading": "Live-Journal wird geladen...",
|
||||||
|
"live_retry": "Erneut versuchen",
|
||||||
|
"live_load_error": "Live-Journal konnte nicht geladen werden.",
|
||||||
|
"live_action_error": "Eintrag konnte nicht gespeichert werden.",
|
||||||
|
"live_open_editor": "Vollständiger Editor",
|
||||||
|
"live_actions_label": "Schnellaktionen",
|
||||||
|
"live_stream_label": "Ereignisprotokoll",
|
||||||
|
"live_stream_title": "Journal",
|
||||||
|
"live_no_events": "Noch keine Einträge — tippe auf eine Aktion.",
|
||||||
|
"live_motor_start": "Motor Start",
|
||||||
|
"live_motor_stop": "Motor Stop",
|
||||||
|
"live_cast_off": "Ablegen",
|
||||||
|
"live_moor": "Anlegen",
|
||||||
|
"live_sails_btn": "Segel",
|
||||||
|
"live_sails_pick": "Segel auswählen",
|
||||||
|
"live_sails_pick_hint": "Mehrere Segel antippen (erneut antippen zum Abwählen), dann Eintragen.",
|
||||||
|
"live_sails_selected": "Auswahl: {{sails}}",
|
||||||
|
"live_sails_confirm": "Eintragen",
|
||||||
|
"live_sails_confirm_count": "Eintragen ({{count}})",
|
||||||
|
"live_sails": "Segel: {{sails}}",
|
||||||
|
"live_position": "Position",
|
||||||
|
"live_position_coords": "Position {{lat}}, {{lng}}",
|
||||||
|
"live_position_manual_hint": "GPS nicht verfügbar. Breiten- und Längengrad manuell eingeben oder erneut per GPS-Knopf versuchen.",
|
||||||
|
"live_position_gps_loading": "GPS-Position wird ermittelt…",
|
||||||
|
"live_position_invalid": "Bitte gültige Koordinaten eingeben (Breite −90…90, Länge −180…180).",
|
||||||
|
"live_position_lat_placeholder": "Breite (Lat)",
|
||||||
|
"live_position_lng_placeholder": "Länge (Lng)",
|
||||||
|
"live_photo_btn": "Foto (Kamera)",
|
||||||
|
"live_photo_capture_btn": "Aufnehmen",
|
||||||
|
"live_photo_save_btn": "Speichern",
|
||||||
|
"live_photo_retake_btn": "Neu aufnehmen",
|
||||||
|
"live_photo_capture_failed": "Aufnahme fehlgeschlagen. Bitte erneut versuchen.",
|
||||||
|
"live_photo_open_camera_btn": "Kamera öffnen",
|
||||||
|
"live_photo_native_hint": "Foto mit der Gerätekamera aufnehmen und anschließend hier speichern.",
|
||||||
|
"live_photo_camera_starting": "Kamera wird gestartet…",
|
||||||
|
"live_photo_camera_denied": "Kamerazugriff verweigert oder nicht verfügbar.",
|
||||||
|
"live_photo_camera_unavailable": "Kamera wird von diesem Browser nicht unterstützt.",
|
||||||
|
"live_photo_no_camera": "Auf diesem Gerät ist keine Kamera verfügbar.",
|
||||||
|
"live_photo_error": "Foto konnte nicht gespeichert werden.",
|
||||||
|
"live_photo_entry": "Foto: {{caption}}",
|
||||||
|
"live_photo_entry_plain": "Foto aufgenommen",
|
||||||
|
"live_undo_photo_hint": "Foto gespeichert",
|
||||||
|
"live_voice_btn": "Sprachnotiz",
|
||||||
|
"live_voice_hint": "Kurze Sprachnotiz aufnehmen (max. 60 Sekunden).",
|
||||||
|
"live_voice_record": "Aufnahme starten",
|
||||||
|
"live_voice_stop": "Aufnahme beenden",
|
||||||
|
"live_voice_recording": "Aufnahme {{time}}",
|
||||||
|
"live_voice_save": "Speichern",
|
||||||
|
"live_voice_saving": "Wird gespeichert…",
|
||||||
|
"live_voice_retake": "Neu aufnehmen",
|
||||||
|
"live_voice_mic_denied": "Mikrofonzugriff verweigert oder nicht verfügbar.",
|
||||||
|
"live_voice_record_failed": "Aufnahme fehlgeschlagen. Bitte erneut versuchen.",
|
||||||
|
"live_voice_unavailable": "Sprachnotiz nicht verfügbar",
|
||||||
|
"live_voice_too_large": "Aufnahme ist zu groß. Bitte kürzer aufnehmen.",
|
||||||
|
"live_voice_error": "Sprachnotiz konnte nicht gespeichert werden.",
|
||||||
|
"live_voice_entry": "Sprachnotiz: {{caption}}",
|
||||||
|
"live_voice_entry_plain": "Sprachnotiz",
|
||||||
|
"live_voice_caption_label": "Beschriftung (optional)",
|
||||||
|
"live_voice_caption_placeholder": "z. B. Funkverkehr mit Hafenmeister",
|
||||||
|
"live_voice_transcribe_action": "Transkribieren",
|
||||||
|
"live_voice_transcribing": "Transkribiere...",
|
||||||
|
"live_voice_transcribe_failed": "Sprachmemo gespeichert, aber Transkription fehlgeschlagen.",
|
||||||
|
"live_undo_voice_hint": "Sprachnotiz gespeichert",
|
||||||
|
"live_comment_btn": "Kommentar",
|
||||||
|
"live_comment_placeholder": "Freitext eingeben…",
|
||||||
|
"live_comment_confirm": "Eintragen",
|
||||||
|
"live_gps_error": "GPS-Position konnte nicht ermittelt werden.",
|
||||||
|
"live_gps_start_hint": "Beginne deine Tagesreise immer mit einer Position.",
|
||||||
|
"live_event_generic": "Ereignis",
|
||||||
|
"live_weather_btn": "Wetter",
|
||||||
|
"live_weather_owm_btn": "OpenWeatherMap Wetter abrufen",
|
||||||
|
"live_weather_owm_loading": "Wetter wird geladen…",
|
||||||
|
"live_weather_position_required": "Für Wetter von OpenWeatherMap zuerst eine Position eintragen (Schaltfläche „Position“). Die Position darf höchstens 6 Stunden alt sein.",
|
||||||
|
"live_weather_position_stale": "Die letzte Position ist älter als 6 Stunden. Bitte erneut eine Position loggen, bevor du Wetter abrufst.",
|
||||||
|
"live_wind_btn": "Wind",
|
||||||
|
"live_temp_btn": "T °C",
|
||||||
|
"live_pressure_btn": "Luftdruck",
|
||||||
|
"live_precip_btn": "Niederschlag",
|
||||||
|
"live_sea_state_btn": "Seegang",
|
||||||
|
"live_visibility_btn": "Sichtweite",
|
||||||
|
"live_course_btn": "Kurs",
|
||||||
|
"live_fuel_btn": "+ Diesel",
|
||||||
|
"live_water_btn": "+ Wasser",
|
||||||
|
"live_wind_entry": "Wind {{value}}",
|
||||||
|
"live_temp_entry": "Temperatur {{temp}} °C",
|
||||||
|
"live_pressure_entry": "Luftdruck {{value}} hPa",
|
||||||
|
"live_precip_entry": "Niederschlag {{value}}",
|
||||||
|
"live_sea_state_entry": "Seegang {{value}}",
|
||||||
|
"live_visibility_entry": "Sichtweite {{value}}",
|
||||||
|
"live_course_entry": "Kurs {{course}}",
|
||||||
|
"live_fuel_entry": "Diesel +{{liters}} L",
|
||||||
|
"live_water_entry": "Wasser +{{liters}} L",
|
||||||
|
"live_auto_position": "Auto-Position",
|
||||||
|
"live_undo_hint": "Eintrag gespeichert",
|
||||||
|
"live_undo_btn": "Rückgängig",
|
||||||
|
"live_cancel": "Abbruch",
|
||||||
|
"live_pressure_placeholder": "z. B. 1013",
|
||||||
|
"live_temp_placeholder": "z. B. 18",
|
||||||
|
"live_precip_placeholder": "z. B. leichter Regen",
|
||||||
|
"live_sea_state_placeholder": "z. B. 3",
|
||||||
|
"live_visibility_placeholder": "z. B. 10 km",
|
||||||
|
"live_course_placeholder": "z. B. 245",
|
||||||
|
"live_fuel_placeholder": "Nachgefüllte Liter",
|
||||||
|
"live_water_placeholder": "Nachgefüllte Liter",
|
||||||
|
"live_sog_btn": "SOG",
|
||||||
|
"live_stw_btn": "STW",
|
||||||
|
"live_sog_entry": "SOG {{speed}} kn",
|
||||||
|
"live_stw_entry": "STW {{speed}} kn",
|
||||||
|
"live_sog_placeholder": "z. B. 5,2",
|
||||||
|
"live_stw_placeholder": "z. B. 4,8",
|
||||||
|
"live_sog_hint": "Fahrt über Grund (kn) — GPS-Wert wird vorgefüllt, wenn verfügbar.",
|
||||||
"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",
|
||||||
|
"event_creator": "Eingetragen von",
|
||||||
"no_events": "Noch keine Ereignisse für diesen Reisetag eingetragen.",
|
"no_events": "Noch keine Ereignisse für diesen Reisetag eingetragen.",
|
||||||
"event_time": "Uhrzeit",
|
"event_time": "Uhrzeit",
|
||||||
"event_mgk": "MgK Kurs",
|
"event_mgk": "MgK Kurs",
|
||||||
"event_rwk": "RwK Kurs",
|
"event_rwk": "RwK Kurs",
|
||||||
|
"event_course_section": "Kurs",
|
||||||
|
"course_dial_hint": "Am Ring drehen oder Grad eingeben",
|
||||||
|
"course_dial_step_label": "Schrittweite",
|
||||||
|
"course_step_fine": "1°",
|
||||||
|
"course_step_medium": "5°",
|
||||||
|
"course_step_coarse": "10°",
|
||||||
|
"course_tab_mgk": "MgK",
|
||||||
|
"course_tab_rwk": "rwK",
|
||||||
|
"course_invalid": "Ungültiger Kurs (0–360)",
|
||||||
|
"course_placeholder_degrees": "z. B. 180",
|
||||||
|
"course_placeholder_cardinal": "z. B. NW",
|
||||||
|
"compass_n": "N",
|
||||||
|
"compass_e": "O",
|
||||||
|
"compass_s": "S",
|
||||||
|
"compass_w": "W",
|
||||||
|
"wind_mode_cardinal": "Kardinal",
|
||||||
|
"wind_mode_degrees": "Als Grad",
|
||||||
"event_wind_direction": "Wind-Richtung",
|
"event_wind_direction": "Wind-Richtung",
|
||||||
"event_wind_strength": "Windstärke",
|
"event_wind_strength": "Windstärke",
|
||||||
"event_sea_state": "Seegang",
|
"event_sea_state": "Seegang",
|
||||||
|
"event_visibility": "Sichtweite",
|
||||||
|
"event_visibility_placeholder": "z. B. 10 km",
|
||||||
|
"weather_slider_unset": "—",
|
||||||
|
"weather_slider_pressure": "{{value}} hPa",
|
||||||
|
"weather_slider_sea_state": "Stufe {{value}}",
|
||||||
|
"weather_slider_heel": "{{value}}°",
|
||||||
"event_weather": "Wetter",
|
"event_weather": "Wetter",
|
||||||
"event_log": "Logge (sm)",
|
"event_log": "Logge (sm)",
|
||||||
"event_gps": "GPS-Position",
|
"event_gps": "GPS-Position",
|
||||||
@@ -200,11 +399,32 @@
|
|||||||
"event_location_placeholder": "z. B. Kiel",
|
"event_location_placeholder": "z. B. Kiel",
|
||||||
"event_remarks": "Bemerkungen / Vorkommnisse",
|
"event_remarks": "Bemerkungen / Vorkommnisse",
|
||||||
"gps_btn": "GPS-Koordinaten abrufen",
|
"gps_btn": "GPS-Koordinaten abrufen",
|
||||||
|
"gps_permission_denied": "Standortzugriff wurde verweigert. Bitte in den Browser- oder Geräteeinstellungen erlauben und erneut versuchen.",
|
||||||
|
"gps_timeout": "GPS-Zeitüberschreitung. Bitte erneut versuchen – am besten im Freien mit gutem Empfang.",
|
||||||
|
"gps_position_unavailable": "Kein GPS-Signal verfügbar. Bitte warten oder Koordinaten manuell eingeben.",
|
||||||
|
"gps_unavailable": "GPS wird von diesem Browser oder Gerät nicht unterstützt.",
|
||||||
|
"gps_failed": "GPS-Position konnte nicht ermittelt werden.",
|
||||||
|
"gps_fallback_no_location": "GPS fehlgeschlagen. Bitte einen Ort unter „Ort / Hafen“, Start- oder Zielhafen eintragen, oder Koordinaten manuell eingeben.",
|
||||||
|
"gps_fallback_success": "Koordinaten für „{{location}}“ über den Ortsnamen ermittelt (nicht per GPS).",
|
||||||
|
"gps_fallback_failed": "GPS und Ortsnamen-Suche sind fehlgeschlagen. Bitte Koordinaten manuell eingeben.",
|
||||||
|
"gps_quality_excellent": "Starker GPS-Empfang (±{{accuracy}} m)",
|
||||||
|
"gps_quality_good": "Guter GPS-Empfang (±{{accuracy}} m)",
|
||||||
|
"gps_quality_fair": "Mäßiger GPS-Empfang (±{{accuracy}} m) – für besseren Empfang ins Freie gehen.",
|
||||||
|
"gps_quality_poor": "Schwacher GPS-Empfang (±{{accuracy}} m) – vermutlich wenig Satelliten. Im Freien erneut versuchen oder Position prüfen.",
|
||||||
|
"gps_quality_unknown": "GPS-Position übernommen (Genauigkeit vom Gerät nicht gemeldet).",
|
||||||
|
"gps_live_intro_title": "Standort für Live-Log",
|
||||||
|
"gps_live_intro_body": "Für automatische Positions-Einträge und den GPS-Knopf braucht die App Zugriff auf deinen Standort.\n\nTippe auf „Standort erlauben“ – im nächsten Dialog die Freigabe bestätigen. Du kannst jederzeit manuell unter „Position“ eintragen.",
|
||||||
|
"gps_live_intro_allow": "Standort erlauben",
|
||||||
|
"gps_live_intro_later": "Später",
|
||||||
|
"gps_enable_in_settings_hint": "Standortzugriff ist blockiert. In den Browser- oder Geräteeinstellungen (Website / App → Standort) kannst du die Freigabe nachträglich erlauben.",
|
||||||
"weather_btn": "OpenWeatherMap Wetter abrufen",
|
"weather_btn": "OpenWeatherMap Wetter abrufen",
|
||||||
|
"weather_offline": "OpenWeatherMap erfordert eine Internetverbindung. Du bist derzeit offline.",
|
||||||
"event_wind_pressure": "Luftdruck (hPa)",
|
"event_wind_pressure": "Luftdruck (hPa)",
|
||||||
"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)",
|
||||||
@@ -212,10 +432,24 @@
|
|||||||
"share_csv": "CSV teilen",
|
"share_csv": "CSV teilen",
|
||||||
"export_pdf": "PDF herunterladen",
|
"export_pdf": "PDF herunterladen",
|
||||||
"exporting_pdf": "PDF wird generiert...",
|
"exporting_pdf": "PDF wird generiert...",
|
||||||
"photos_title": "Foto-Anhänge (E2E-verschlüsselt)",
|
"ai_summary_title": "KI-Zusammenfassung",
|
||||||
|
"ai_summary_read_only": "Vom Skipper erstellt — nur lesbar für die Crew.",
|
||||||
|
"ai_summary_empty": "Noch keine Zusammenfassung vorhanden.",
|
||||||
|
"ai_summary_generate": "Zusammenfassung generieren",
|
||||||
|
"ai_summary_regenerate": "Neu generieren",
|
||||||
|
"ai_summary_generating": "Wird generiert…",
|
||||||
|
"ai_summary_attempts_remaining": "Noch {{remaining}} von {{max}} Versuchen",
|
||||||
|
"ai_summary_error": "KI-Zusammenfassung fehlgeschlagen. Bitte später erneut versuchen.",
|
||||||
|
"ai_summary_error_no_key": "Kein OpenRouter API-Schlüssel auf dem Server konfiguriert.",
|
||||||
|
"ai_summary_error_rate_limited": "Maximale Anzahl an Generierungen für diesen Reisetag erreicht.",
|
||||||
|
"ai_summary_error_forbidden": "Nur der Skipper darf KI-Zusammenfassungen generieren.",
|
||||||
|
"ai_summary_offline": "Die KI-Zusammenfassung erfordert eine Internetverbindung. Du bist derzeit offline.",
|
||||||
|
"photos_title": "Foto-Anhänge",
|
||||||
"photo_caption_label": "Foto-Beschreibung / Label (Optional)",
|
"photo_caption_label": "Foto-Beschreibung / Label (Optional)",
|
||||||
"photo_caption_placeholder": "z.B. Segel setzen nahe Hafeneinfahrt",
|
"photo_caption_placeholder": "z.B. Segel setzen nahe Hafeneinfahrt",
|
||||||
"photo_btn": "Foto aufnehmen / Hochladen",
|
"photo_btn": "Foto aufnehmen / Hochladen",
|
||||||
|
"photo_camera_btn": "Foto aufnehmen",
|
||||||
|
"photo_gallery_btn": "Aus Galerie wählen",
|
||||||
"photo_processing": "Wird verarbeitet...",
|
"photo_processing": "Wird verarbeitet...",
|
||||||
"no_photos": "Noch keine Fotos an diesen Reisetag angehängt.",
|
"no_photos": "Noch keine Fotos an diesen Reisetag angehängt.",
|
||||||
"photo_delete_confirm": "Bist du sicher, dass du dieses Foto unwiderruflich löschen möchtest?",
|
"photo_delete_confirm": "Bist du sicher, dass du dieses Foto unwiderruflich löschen möchtest?",
|
||||||
@@ -236,6 +470,56 @@
|
|||||||
"track_map_end": "Ziel",
|
"track_map_end": "Ziel",
|
||||||
"track_map_speed_slow": "langsam",
|
"track_map_speed_slow": "langsam",
|
||||||
"track_map_speed_fast": "schnell",
|
"track_map_speed_fast": "schnell",
|
||||||
|
"nmea_import_title": "NMEA-Protokoll importieren",
|
||||||
|
"nmea_import_intro": "Lade eine .nmea-Datei vom Bord-Logger. Die App schlägt Journal-Einträge vor — du entscheidest, was übernommen wird.",
|
||||||
|
"nmea_import_btn": "NMEA importieren",
|
||||||
|
"nmea_file_label": "NMEA-Datei",
|
||||||
|
"nmea_stats": "{{lines}} Sätze erkannt · Typen: {{types}}",
|
||||||
|
"nmea_warn_no_position": "Keine Positions-Sätze gefunden — Track und GPS-Felder können leer bleiben.",
|
||||||
|
"nmea_warn_duplicate_file": "Diese NMEA-Datei wurde bereits importiert. Ein erneuter Import derselben Datei fügt doppelte Journal-Einträge hinzu.",
|
||||||
|
"nmea_mode_label": "Journal-Einträge erzeugen",
|
||||||
|
"nmea_mode_interval": "Nach Zeitintervall",
|
||||||
|
"nmea_mode_change": "Bei signifikanter Änderung",
|
||||||
|
"nmea_mode_both": "Beides (zusammenführen)",
|
||||||
|
"nmea_interval_label": "Intervall (Minuten)",
|
||||||
|
"nmea_import_track": "GPS-Track aus NMEA übernehmen",
|
||||||
|
"nmea_preview": "Vorschau",
|
||||||
|
"nmea_preview_hint": "{{count}} vorgeschlagene Journal-Einträge",
|
||||||
|
"nmea_select_all": "Alle auswählen",
|
||||||
|
"nmea_select_none": "Keine auswählen",
|
||||||
|
"nmea_source_interval": "Intervall",
|
||||||
|
"nmea_source_change": "Ereignis",
|
||||||
|
"nmea_apply": "In Journal übernehmen",
|
||||||
|
"nmea_back": "Zurück",
|
||||||
|
"nmea_cancel": "Abbrechen",
|
||||||
|
"nmea_archive_question": "Rohprotokoll lokal archivieren? (Nur auf diesem Gerät, nicht synchronisiert.)",
|
||||||
|
"nmea_archive_keep": "Archivieren",
|
||||||
|
"nmea_archive_discard": "Verwerfen",
|
||||||
|
"nmea_archive_stored": "NMEA archiviert: {{name}}",
|
||||||
|
"nmea_archive_delete_confirm": "Archiviertes NMEA-Protokoll von diesem Gerät löschen?",
|
||||||
|
"nmea_error_no_samples": "Keine verwertbaren NMEA-Sätze in der Datei.",
|
||||||
|
"nmea_error_parse": "NMEA-Datei konnte nicht gelesen werden.",
|
||||||
|
"nmea_error_read": "Datei konnte nicht gelesen werden.",
|
||||||
|
"nmea_error_no_file": "Bitte zuerst eine NMEA-Datei wählen.",
|
||||||
|
"nmea_error_no_selection": "Bitte mindestens einen Journal-Eintrag auswählen.",
|
||||||
|
"nmea_remark_interval": "NMEA Intervall",
|
||||||
|
"nmea_remark_uncertain": "unsicher",
|
||||||
|
"nmea_remark_depth": "Tiefe {{depth}} m",
|
||||||
|
"nmea_change_course": "Kursänderung {{from}}° → {{to}}°",
|
||||||
|
"nmea_change_wind": "Wind {{from}}° → {{to}}°",
|
||||||
|
"nmea_change_wind_speed": "Wind {{from}} → {{to}} kn",
|
||||||
|
"nmea_change_pressure": "Luftdruck {{from}} → {{to}} hPa",
|
||||||
|
"nmea_change_depth": "Tiefe {{from}} → {{to}} m",
|
||||||
|
"nmea_change_engine_start": "Motor an ({{rpm}} U/min)",
|
||||||
|
"nmea_change_engine_stop": "Motor aus",
|
||||||
|
"nmea_change_autopilot_on": "Autopilot ein",
|
||||||
|
"nmea_change_autopilot_off": "Autopilot aus",
|
||||||
|
"nmea_change_gps_lost": "GPS-Position verloren",
|
||||||
|
"nmea_change_gps_regained": "GPS-Position wiederhergestellt",
|
||||||
|
"nmea_change_water_temp": "Wassertemp. {{from}} → {{to}} °C",
|
||||||
|
"nmea_change_departure": "Abfahrt / Fahrtbeginn",
|
||||||
|
"nmea_change_anchor": "Ankern / Stop",
|
||||||
|
"nmea_change_speed": "Geschw. {{from}} → {{to}} kn",
|
||||||
"track_map_error": "Karte konnte nicht geladen werden.",
|
"track_map_error": "Karte konnte nicht geladen werden.",
|
||||||
"exporting": "Exportiere...",
|
"exporting": "Exportiere...",
|
||||||
"share_unsupported": "Teilen wird auf diesem Gerät nicht unterstützt. Datei wurde stattdessen heruntergeladen.",
|
"share_unsupported": "Teilen wird auf diesem Gerät nicht unterstützt. Datei wurde stattdessen heruntergeladen.",
|
||||||
@@ -255,9 +539,12 @@
|
|||||||
"new_logbook_placeholder": "Name des Logbuchs oder der Yacht",
|
"new_logbook_placeholder": "Name des Logbuchs oder der Yacht",
|
||||||
"logout": "Abmelden",
|
"logout": "Abmelden",
|
||||||
"logged_in_as": "Angemeldet als {{name}}",
|
"logged_in_as": "Angemeldet als {{name}}",
|
||||||
"delete_confirm": "Bist du sicher, dass du dieses Logbuch unwiderruflich löschen möchtest? Alle lokalen Daten und Server-Kopien werden vernichtet.\n\nTipp: Erstelle vorher unter Einstellungen → Backup & Wiederherstellung eine Sicherungskopie (.daagbok.json), falls du die Daten später behalten möchtest.",
|
"delete_confirm": "Bist du sicher, dass du dieses Logbuch unwiderruflich löschen möchtest? Alle lokalen Daten und Server-Kopien werden vernichtet.\n\nTipp: Erstelle vorher unter Einstellungen → Backup & Wiederherstellung eine Sicherungskopie (.daagbok), falls du die Daten später behalten möchtest.",
|
||||||
"no_logbooks": "Keine Logbücher gefunden. Erstelle dein erstes Logbuch, um zu beginnen!",
|
"no_logbooks": "Keine Logbücher gefunden. Erstelle dein erstes Logbuch, um zu beginnen!",
|
||||||
"loading": "Logbücher werden geladen...",
|
"loading": "Logbücher werden geladen...",
|
||||||
|
"travel_days_count_zero": "Keine Reisetage",
|
||||||
|
"travel_days_count_one": "1 Reisetag",
|
||||||
|
"travel_days_count_other": "{{count}} Reisetage",
|
||||||
"status_synced": "Synchronisiert",
|
"status_synced": "Synchronisiert",
|
||||||
"status_local": "Nur lokaler Cache",
|
"status_local": "Nur lokaler Cache",
|
||||||
"delete_btn": "Logbuch löschen",
|
"delete_btn": "Logbuch löschen",
|
||||||
@@ -269,7 +556,217 @@
|
|||||||
"role_crew": "Crew-Zugang",
|
"role_crew": "Crew-Zugang",
|
||||||
"role_crew_hint": "Eingeladenes Logbuch — du kannst als Crew mitarbeiten und signieren",
|
"role_crew_hint": "Eingeladenes Logbuch — du kannst als Crew mitarbeiten und signieren",
|
||||||
"role_read": "Nur Lesen",
|
"role_read": "Nur Lesen",
|
||||||
"role_read_hint": "Geteiltes Logbuch — nur Ansicht, keine Bearbeitung"
|
"role_read_hint": "Geteiltes Logbuch — nur Ansicht, keine Bearbeitung",
|
||||||
|
"open_profile": "Profil von {{name}} öffnen",
|
||||||
|
"open_logbook": "Logbuch „{{title}}“ öffnen",
|
||||||
|
"edit_title": "Logbuch umbenennen",
|
||||||
|
"edit_placeholder": "Neuer Name des Logbuchs",
|
||||||
|
"edit_success": "Logbuch erfolgreich umbenannt",
|
||||||
|
"edit_btn": "Umbenennen",
|
||||||
|
"filter_label": "Logbücher filtern",
|
||||||
|
"filter_placeholder": "Name, Jahr, Datum, Crew oder Schiff …",
|
||||||
|
"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": {
|
||||||
|
"title": "Benutzerprofil",
|
||||||
|
"subtitle": "Konto, Passkeys und Statistiken für {{name}}",
|
||||||
|
"back": "Zurück zum Dashboard",
|
||||||
|
"loading": "Profil wird geladen…",
|
||||||
|
"load_error": "Profil konnte nicht geladen werden.",
|
||||||
|
"copy_failed": "Kopieren fehlgeschlagen.",
|
||||||
|
"processing": "Wird verarbeitet…",
|
||||||
|
"identity_title": "Konto-Identität",
|
||||||
|
"username": "Benutzername",
|
||||||
|
"user_id": "Benutzer-ID",
|
||||||
|
"copy_user_id": "Benutzer-ID kopieren",
|
||||||
|
"account_since": "Konto seit",
|
||||||
|
"prf_status": "Passkey-Schlüsselableitung (PRF)",
|
||||||
|
"prf_active": "Aktiv",
|
||||||
|
"prf_inactive": "Nicht eingerichtet",
|
||||||
|
"passkeys_title": "Passkeys",
|
||||||
|
"passkeys_desc": "Registriere auf jedem Gerät einen eigenen Passkey. So kannst du dich auch nach einem Plattformwechsel anmelden.",
|
||||||
|
"passkeys_empty": "Keine Passkeys gefunden.",
|
||||||
|
"add_passkey_btn": "Neuen Passkey hinzufügen",
|
||||||
|
"add_passkey_success": "Passkey erfolgreich hinzugefügt.",
|
||||||
|
"add_passkey_failed": "Passkey konnte nicht hinzugefügt werden.",
|
||||||
|
"remove_passkey_btn": "Passkey entfernen",
|
||||||
|
"remove_passkey_last_title": "Letzter Passkey",
|
||||||
|
"remove_passkey_last_desc": "Der einzige Passkey kann nicht entfernt werden, ohne den Zugang zu deinem Konto zu verlieren. Um das Konto vollständig zu löschen, nutze die Gefahrenzone am Ende dieser Seite.",
|
||||||
|
"remove_passkey_failed": "Passkey konnte nicht entfernt werden.",
|
||||||
|
"remove_passkey_confirm_title": "Passkey entfernen?",
|
||||||
|
"remove_passkey_confirm_desc": "Dieses Gerät kann sich danach nicht mehr mit diesem Passkey anmelden.",
|
||||||
|
"remove_passkey_confirm_yes": "Entfernen",
|
||||||
|
"remove_passkey_confirm_no": "Abbrechen",
|
||||||
|
"pin_title": "Lokaler PIN",
|
||||||
|
"pin_status": "Status",
|
||||||
|
"pin_active": "Aktiv auf diesem Gerät",
|
||||||
|
"pin_inactive": "Nicht eingerichtet",
|
||||||
|
"pin_confirm_label": "PIN bestätigen",
|
||||||
|
"pin_confirm_placeholder": "PIN erneut eingeben",
|
||||||
|
"pin_set_btn": "PIN einrichten",
|
||||||
|
"pin_change_btn": "PIN ändern",
|
||||||
|
"pin_remove_btn": "PIN entfernen",
|
||||||
|
"pin_saved": "PIN gespeichert.",
|
||||||
|
"pin_save_failed": "PIN konnte nicht gespeichert werden.",
|
||||||
|
"pin_mismatch": "Die PIN-Eingaben stimmen nicht überein.",
|
||||||
|
"pin_length_error": "Die PIN muss mindestens 4 Zeichen haben.",
|
||||||
|
"pin_no_session": "Sitzung abgelaufen — bitte erneut anmelden.",
|
||||||
|
"remove_pin_confirm_title": "PIN entfernen?",
|
||||||
|
"remove_pin_confirm_desc": "Du musst dich auf diesem Gerät wieder mit Passkey oder Wiederherstellungsschlüssel anmelden.",
|
||||||
|
"remove_pin_confirm_yes": "PIN entfernen",
|
||||||
|
"remove_pin_confirm_no": "Abbrechen",
|
||||||
|
"security_title": "Sicherheits-Checkliste",
|
||||||
|
"security_desc": "Überblick über die wichtigsten Schutzmechanismen deines Kontos.",
|
||||||
|
"security_passkeys_ok": "Mindestens ein Passkey registriert",
|
||||||
|
"security_passkeys_missing": "Kein Passkey registriert",
|
||||||
|
"security_prf_ok": "PRF-Schlüsselableitung aktiv",
|
||||||
|
"security_prf_missing": "PRF nicht eingerichtet",
|
||||||
|
"security_pin_ok": "Lokaler PIN auf diesem Gerät",
|
||||||
|
"security_pin_missing": "Kein lokaler PIN",
|
||||||
|
"security_recovery_ok": "Wiederherstellungsschlüssel eingerichtet",
|
||||||
|
"security_recovery_hint": "Die 12 Wörter wurden bei der Registrierung angezeigt. Bewahre sie offline und getrennt vom Gerät auf. Du kannst unten einen neuen Schlüssel erstellen — der alte wird dann ungültig.",
|
||||||
|
"recovery_rotate_btn": "Neuen Wiederherstellungsschlüssel erstellen",
|
||||||
|
"recovery_rotate_confirm_title": "Neuen Wiederherstellungsschlüssel erstellen?",
|
||||||
|
"recovery_rotate_confirm_desc": "Der bisherige 12-Wörter-Schlüssel wird sofort ungültig. Stelle sicher, dass du den neuen Schlüssel sicher aufbewahrst, bevor du fortfährst.",
|
||||||
|
"recovery_rotate_confirm_yes": "Neuen Schlüssel erstellen",
|
||||||
|
"recovery_rotate_confirm_no": "Abbrechen",
|
||||||
|
"recovery_rotate_new_warning": "WICHTIG: Schreib diese 12 Wörter auf und bewahre sie offline auf. Der bisherige Wiederherstellungsschlüssel ist ab sofort ungültig.",
|
||||||
|
"recovery_rotate_failed": "Wiederherstellungsschlüssel konnte nicht erstellt werden.",
|
||||||
|
"recovery_rotate_no_session": "Verschlüsselungssitzung abgelaufen — bitte abmelden und erneut anmelden, dann erneut versuchen.",
|
||||||
|
"device_title": "Dieses Gerät",
|
||||||
|
"device_desc": "Lokaler Cache, Sync-Status und Schnell-Login auf diesem Browser.",
|
||||||
|
"device_sync_pending": "{{count}} ausstehende Sync-Einträge",
|
||||||
|
"device_sync_ok": "Alle lokalen Änderungen synchronisiert",
|
||||||
|
"device_remembered": "Account für Schnell-Login auf diesem Gerät gespeichert",
|
||||||
|
"device_not_remembered": "Account nicht in der Schnell-Login-Liste",
|
||||||
|
"device_forget_btn": "Account auf diesem Gerät vergessen",
|
||||||
|
"device_forget_confirm_title": "Schnell-Login entfernen?",
|
||||||
|
"device_forget_confirm_desc": "Der Account verschwindet aus der Schnell-Login-Liste auf diesem Gerät. Deine Session und lokalen Logbücher bleiben erhalten.",
|
||||||
|
"device_forget_confirm_yes": "Entfernen",
|
||||||
|
"device_forget_confirm_no": "Abbrechen",
|
||||||
|
"passkey_label": "Name für neuen Passkey (optional)",
|
||||||
|
"passkey_label_placeholder": "z. B. MacBook, iPhone",
|
||||||
|
"passkey_rename_btn": "Name speichern",
|
||||||
|
"passkey_rename_success": "Passkey-Name gespeichert.",
|
||||||
|
"passkey_rename_failed": "Passkey-Name konnte nicht gespeichert werden.",
|
||||||
|
"passkey_unnamed": "Unbenannter Passkey",
|
||||||
|
"stats_title": "Statistiken",
|
||||||
|
"stats_subtitle": "Über alle deine Logbücher auf diesem Gerät",
|
||||||
|
"stats_logbooks": "Logbücher",
|
||||||
|
"stats_account_since": "Konto seit",
|
||||||
|
"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.",
|
||||||
|
"ai_title": "KI-Funktionen & Datenschutz",
|
||||||
|
"ai_desc": "Autorisiere die Nutzung von künstlicher Intelligenz (lokale/Cloud-Integrationen) für deine Logbücher.",
|
||||||
|
"ai_help": "Die Aktivierung ermöglicht es, Reiseberichte automatisch zusammenzufassen und Sprachnotizen zu transkribieren. Zur Verarbeitung werden Sprachaufnahmen und Logbucheinträge verschlüsselt an OpenRouter übertragen. Die Daten werden dort nicht dauerhaft gespeichert.\n\nDa der Betrieb dieser Cloud-Ressourcen Kosten verursacht, freuen wir uns über eine freiwillige Unterstützung über den Ko-fi-Spenden-Link im Footer, um diese Funktionen dauerhaft für alle kostenlos anbieten zu können.",
|
||||||
|
"ai_enable_label": "Transkribierung und Tageszusammenfassungen aktivieren",
|
||||||
|
"ai_unauthorized_alert_title": "KI-Funktionen nicht autorisiert",
|
||||||
|
"ai_unauthorized_alert_desc": "Um Sprachnotizen zu transkribieren oder Reiseberichte zusammenzufassen, musst du der Datenübermittlung an OpenRouter in deinem Benutzerprofil unter 'KI-Funktionen & Datenschutz' zustimmen.",
|
||||||
|
"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.",
|
||||||
|
"sections": {
|
||||||
|
"account": "Konto & Einstellungen",
|
||||||
|
"fleet": "Flotte & Crew",
|
||||||
|
"security": "Sicherheit & Gerät",
|
||||||
|
"stats": "Statistik",
|
||||||
|
"danger": "Gefahrenzone"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"vessel_pool": {
|
||||||
|
"title": "Schiffsflotte",
|
||||||
|
"section_title": "Deine Schiffe",
|
||||||
|
"subtitle": "Pflege hier alle Schiffe für deine Logbücher. Pro Logbuch wählst du das aktive Schiff aus dieser Liste.",
|
||||||
|
"loading": "Schiffsflotte wird geladen…",
|
||||||
|
"add_vessel": "Schiff hinzufügen",
|
||||||
|
"edit_vessel": "Schiff bearbeiten",
|
||||||
|
"no_vessels": "Noch keine Schiffe im Pool.",
|
||||||
|
"delete_confirm": "Dieses Schiff wirklich aus der Flotte entfernen?",
|
||||||
|
"max_vessels": "Maximale Anzahl von 20 Schiffen im Pool erreicht."
|
||||||
|
},
|
||||||
|
"logbook_vessel": {
|
||||||
|
"title": "Schiff für dieses Logbuch",
|
||||||
|
"subtitle": "Wähle das Schiff für dieses Logbuch. Reisetage nutzen Segel- und Tankdaten des gewählten Schiffs.",
|
||||||
|
"active_vessel": "Schiff für dieses Logbuch",
|
||||||
|
"no_vessels_in_pool": "Kein Schiff in der Flotte – zuerst im Benutzerprofil anlegen.",
|
||||||
|
"no_vessel": "Kein Schiff gewählt",
|
||||||
|
"unnamed": "Unbenannt",
|
||||||
|
"save": "Schiff speichern",
|
||||||
|
"saved": "Schiff für das Logbuch gespeichert.",
|
||||||
|
"selection_only_hint": "Du siehst das vom Eigner gewählte Schiff (geteiltes Logbuch).",
|
||||||
|
"manage_in_profile": "Schiffe im Benutzerprofil verwalten"
|
||||||
|
},
|
||||||
|
"person_pool": {
|
||||||
|
"title": "Stammcrew & Skipper",
|
||||||
|
"subtitle": "Lege hier deinen Personen-Pool an – Skipper und Crew für alle Logbücher. Aus diesem Pool wählst du pro Logbuch und Reisetag die aktive Crew.",
|
||||||
|
"loading": "Personen-Pool wird geladen…",
|
||||||
|
"skippers_section": "Stammskipper",
|
||||||
|
"crew_section": "Stammcrew",
|
||||||
|
"add_skipper": "Skipper hinzufügen",
|
||||||
|
"add_crew": "Crew-Mitglied hinzufügen",
|
||||||
|
"edit_skipper": "Skipper bearbeiten",
|
||||||
|
"no_skippers": "Noch kein Skipper im Pool.",
|
||||||
|
"no_crew": "Noch keine Crew-Mitglieder im Pool.",
|
||||||
|
"delete_confirm": "Diese Person wirklich aus dem Pool entfernen?"
|
||||||
|
},
|
||||||
|
"logbook_crew": {
|
||||||
|
"title": "Crew für dieses Logbuch",
|
||||||
|
"subtitle": "Wähle Skipper und Crew für dieses Logbuch. Neue Reisetage übernehmen diese Auswahl standardmäßig.",
|
||||||
|
"loading": "Crew wird geladen…",
|
||||||
|
"active_skipper": "Skipper für dieses Logbuch",
|
||||||
|
"active_crew": "Crew für dieses Logbuch",
|
||||||
|
"no_skippers_in_pool": "Kein Skipper im Pool – zuerst im Benutzerprofil anlegen.",
|
||||||
|
"no_crew_in_pool": "Keine Crew im Pool – zuerst im Benutzerprofil anlegen.",
|
||||||
|
"no_skipper": "Kein Skipper gewählt",
|
||||||
|
"unnamed": "Unbenannt",
|
||||||
|
"save": "Crew speichern",
|
||||||
|
"saved": "Crew für das Logbuch gespeichert.",
|
||||||
|
"selection_only_hint": "Du siehst die vom Eigner festgelegte Crew (geteiltes Logbuch)."
|
||||||
|
},
|
||||||
|
"entry_crew": {
|
||||||
|
"title": "Crew an diesem Reisetag",
|
||||||
|
"subtitle": "Kann vom Logbuch-Standard abweichen. Folge-Reisetage übernehmen den Vortag.",
|
||||||
|
"day_skipper": "Skipper an diesem Tag",
|
||||||
|
"day_crew": "Crew an diesem Tag",
|
||||||
|
"no_skipper": "Kein Skipper gewählt",
|
||||||
|
"no_crew": "Keine Crew gewählt"
|
||||||
},
|
},
|
||||||
"crew": {
|
"crew": {
|
||||||
"title": "Skipper- & Crew-Profile",
|
"title": "Skipper- & Crew-Profile",
|
||||||
@@ -279,7 +776,7 @@
|
|||||||
"add_crew": "Crew-Mitglied hinzufügen",
|
"add_crew": "Crew-Mitglied hinzufügen",
|
||||||
"edit_crew": "Crew-Mitglied bearbeiten",
|
"edit_crew": "Crew-Mitglied bearbeiten",
|
||||||
"no_crew": "Noch keine Crew-Mitglieder hinzugefügt.",
|
"no_crew": "Noch keine Crew-Mitglieder hinzugefügt.",
|
||||||
"max_crew": "Maximale Anzahl von 5 Crew-Mitgliedern erreicht.",
|
"max_crew": "Maximale Anzahl von 12 Crew-Mitgliedern im Pool erreicht.",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"address": "Anschrift",
|
"address": "Anschrift",
|
||||||
"birthdate": "Geburtstag",
|
"birthdate": "Geburtstag",
|
||||||
@@ -306,35 +803,25 @@
|
|||||||
"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_unauthorized": "Wetterdatenabruf fehlgeschlagen. Der API-Schlüssel ist ungültig oder nicht autorisiert.",
|
||||||
|
"weather_not_found": "Wetterdatenabruf fehlgeschlagen. Der angegebene Ort oder die Koordinaten wurden nicht gefunden.",
|
||||||
|
"weather_bad_request": "Wetterdatenabruf fehlgeschlagen. Es wurde kein Ort und keine GPS-Position angegeben.",
|
||||||
"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_enable": "Öffentlichen Link aktivieren",
|
"share_enable": "Öffentlichen Link aktivieren",
|
||||||
"share_copied": "Link kopiert!",
|
"share_copied": "Link kopiert!",
|
||||||
"share_copy_btn": "Link kopieren",
|
"share_copy_btn": "Link kopieren",
|
||||||
|
"link_qr_hint": "QR-Code zum Scannen mit dem Smartphone",
|
||||||
|
"link_qr_alt": "QR-Code für den Link",
|
||||||
"danger_zone_title": "Gefahrenzone",
|
"danger_zone_title": "Gefahrenzone",
|
||||||
"danger_zone_desc": "Durch das Löschen deines Kontos werden alle deine Passkeys, Logbücher, Schiffsdaten, Crew-Profile, Reiseeinträge und E2E-Schlüssel unwiderruflich gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
|
"danger_zone_desc": "Durch das Löschen deines Kontos werden alle deine Passkeys, Logbücher, Schiffsdaten, Crew-Profile, Reiseeinträge und E2E-Schlüssel unwiderruflich gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||||
"delete_account_btn": "Konto unwiderruflich löschen",
|
"delete_account_btn": "Konto unwiderruflich löschen",
|
||||||
@@ -343,22 +830,18 @@
|
|||||||
"delete_account_confirm_yes": "Ja, Konto und alle Daten löschen",
|
"delete_account_confirm_yes": "Ja, Konto und alle Daten löschen",
|
||||||
"delete_account_confirm_no": "Abbrechen",
|
"delete_account_confirm_no": "Abbrechen",
|
||||||
"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) 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, Sprachnotizen, 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",
|
||||||
"backup_export_desc": "Lädt alle lokalen Daten als .daagbok.json herunter. Bewahre Datei und Passphrase getrennt und sicher auf.",
|
"backup_export_desc": "Lädt alle lokalen Daten als komprimierte .daagbok-Datei herunter. Bewahre Datei und Passphrase getrennt und sicher auf.",
|
||||||
"backup_restore_title": "Backup wiederherstellen",
|
"backup_restore_title": "Backup wiederherstellen",
|
||||||
"backup_restore_desc": "Stellt ein Backup in deinem aktuellen Account wieder her — auch nach Registrierung eines neuen Accounts.",
|
"backup_restore_desc": "Stellt ein Backup in deinem aktuellen Account wieder her — auch nach Registrierung eines neuen Accounts.",
|
||||||
"backup_passphrase": "Backup-Passphrase",
|
"backup_passphrase": "Backup-Passphrase",
|
||||||
@@ -370,7 +853,13 @@
|
|||||||
"backup_export_btn": "Backup herunterladen",
|
"backup_export_btn": "Backup herunterladen",
|
||||||
"backup_exporting": "Backup wird erstellt…",
|
"backup_exporting": "Backup wird erstellt…",
|
||||||
"backup_export_success": "Backup erstellt ({{count}} Reisetage).",
|
"backup_export_success": "Backup erstellt ({{count}} Reisetage).",
|
||||||
"backup_file_label": "Backup-Datei (.daagbok.json)",
|
"backup_file_label": "Backup-Datei (.daagbok)",
|
||||||
|
"backup_export_progress": "Packe Dateien {{current}} / {{total}}…",
|
||||||
|
"backup_invalid_archive": "Die Datei ist kein gültiges Backup-Archiv.",
|
||||||
|
"backup_version_unsupported": "Altes Backup-Format (v1). Bitte ein aktuelles .daagbok-Backup verwenden.",
|
||||||
|
"backup_import_size_confirm": "Dieses Backup ist etwa {{size}} groß. Wiederherstellung kann auf dem Gerät länger dauern und viel Speicher belegen. Fortfahren?",
|
||||||
|
"backup_stat_voice": "{{count}} Sprachnotizen",
|
||||||
|
"backup_stat_size": "Unkomprimiert ca. {{size}}",
|
||||||
"backup_preview_btn": "Inhalt prüfen",
|
"backup_preview_btn": "Inhalt prüfen",
|
||||||
"backup_previewing": "Prüfe…",
|
"backup_previewing": "Prüfe…",
|
||||||
"backup_restore_btn": "Wiederherstellen",
|
"backup_restore_btn": "Wiederherstellen",
|
||||||
@@ -418,6 +907,7 @@
|
|||||||
"category_general": "Allgemein",
|
"category_general": "Allgemein",
|
||||||
"category_bug": "Fehler melden",
|
"category_bug": "Fehler melden",
|
||||||
"category_feature": "Feature-Wunsch",
|
"category_feature": "Feature-Wunsch",
|
||||||
|
"category_translation": "Übersetzungsfehler",
|
||||||
"contact_label": "E-Mail (optional)",
|
"contact_label": "E-Mail (optional)",
|
||||||
"contact_placeholder": "deine@email.beispiel",
|
"contact_placeholder": "deine@email.beispiel",
|
||||||
"message_label": "Nachricht",
|
"message_label": "Nachricht",
|
||||||
@@ -506,7 +996,13 @@
|
|||||||
"unit_l": "L",
|
"unit_l": "L",
|
||||||
"day_label": "Tag {{day}}",
|
"day_label": "Tag {{day}}",
|
||||||
"account_logbooks": "Logbücher im Überblick",
|
"account_logbooks": "Logbücher im Überblick",
|
||||||
"col_logbook": "Logbuch"
|
"col_logbook": "Logbuch",
|
||||||
|
"event_series_title": "Ereignis-Verläufe",
|
||||||
|
"event_series_hint": "Chronologische Werte aus dem Ereignisprotokoll.",
|
||||||
|
"event_series_pressure": "Luftdruck",
|
||||||
|
"event_series_wind": "Wind",
|
||||||
|
"event_series_motor": "Motor",
|
||||||
|
"event_series_empty": "Keine Einträge vorhanden."
|
||||||
},
|
},
|
||||||
"tour": {
|
"tour": {
|
||||||
"skip": "Tour überspringen",
|
"skip": "Tour überspringen",
|
||||||
@@ -521,7 +1017,7 @@
|
|||||||
},
|
},
|
||||||
"welcome_public": {
|
"welcome_public": {
|
||||||
"title": "Willkommen an Bord!",
|
"title": "Willkommen an Bord!",
|
||||||
"body": "Erkunde unser Demo-Logbuch mit drei Reisetagen in der Kieler Förde – ganz ohne Account. Diese kurze Tour zeigt dir Schiffsdaten, Crew und Logbucheinträge."
|
"body": "Erkunde unser Demo-Logbuch mit drei Reisetagen in der Kieler Förde – ganz ohne Account. Die Tour zeigt dir Logbucheinträge, die Schiff- und Crew-Auswahl für dieses Logbuch. Flotte und Stammcrew pflegst du später im Benutzerprofil."
|
||||||
},
|
},
|
||||||
"nav_logs": {
|
"nav_logs": {
|
||||||
"title": "Logbucheinträge",
|
"title": "Logbucheinträge",
|
||||||
@@ -540,12 +1036,20 @@
|
|||||||
"body": "Lade GPX-Dateien hoch oder sieh bereits gespeicherte Routen auf der Karte – inklusive Distanz und Geschwindigkeit."
|
"body": "Lade GPX-Dateien hoch oder sieh bereits gespeicherte Routen auf der Karte – inklusive Distanz und Geschwindigkeit."
|
||||||
},
|
},
|
||||||
"nav_vessel": {
|
"nav_vessel": {
|
||||||
"title": "Schiffsdaten",
|
"title": "Schiff fürs Logbuch",
|
||||||
"body": "Hinterlege Name, Maße und technische Daten deiner Yacht – einmal ausfüllen, für alle Reisetage verfügbar."
|
"body": "Wähle aus deiner Schiffsflotte das Schiff für dieses Logbuch. Schiffe pflegst du im Benutzerprofil unter Flotte & Crew."
|
||||||
},
|
},
|
||||||
"nav_crew": {
|
"profile_vessel_pool": {
|
||||||
"title": "Crew-Liste",
|
"title": "Schiffsflotte",
|
||||||
"body": "Verwalte Besatzungsmitglieder und weise sie später Reisetagen zu."
|
"body": "Im Benutzerprofil legst du alle deine Schiffe an – Charteryachten, eigenes Boot usw. Pro Logbuch wählst du dann das passende Schiff."
|
||||||
|
},
|
||||||
|
"profile_crew_pool": {
|
||||||
|
"title": "Stammcrew & Skipper",
|
||||||
|
"body": "Im Benutzerprofil pflegst du deinen Personen-Pool – mehrere Skipper (z. B. Charter) und Crew-Mitglieder für alle Logbücher."
|
||||||
|
},
|
||||||
|
"nav_logbook_crew": {
|
||||||
|
"title": "Crew pro Logbuch",
|
||||||
|
"body": "Wähle aus dem Pool, wer auf diesem Logbuch als Skipper und Crew gilt. Reisetage übernehmen diese Auswahl standardmäßig."
|
||||||
},
|
},
|
||||||
"nav_stats": {
|
"nav_stats": {
|
||||||
"title": "Statistik-Dashboard",
|
"title": "Statistik-Dashboard",
|
||||||
@@ -555,11 +1059,25 @@
|
|||||||
"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!"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"seo": {
|
||||||
|
"title": "Kapteins Daagbok – Kostenloses digitales Yacht-Logbuch (werbefrei)",
|
||||||
|
"description": "Kostenloses, werbefreies digitales Yacht-Logbuch mit End-to-End-Verschlüsselung und Passkey-Anmeldung. Reisetage, GPS-Tracks, Crew und Schiffsdaten sicher dokumentieren – auch offline als PWA.",
|
||||||
|
"keywords": "Yacht-Logbuch, Schiffstagebuch, Bordlogbuch, Segeln, Passkey, E2E-Verschlüsselung, GPS-Track, maritimes Logbuch, kostenlos, werbefrei, gratis, ohne Werbung",
|
||||||
|
"ogImageAlt": "Kapteins Daagbok Logo"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+574
-56
@@ -6,20 +6,47 @@
|
|||||||
"beta": "Beta",
|
"beta": "Beta",
|
||||||
"beta_hint": "Beta release — features may still change"
|
"beta_hint": "Beta release — features may still change"
|
||||||
},
|
},
|
||||||
|
"footer": {
|
||||||
|
"kofi_label": "Ko-fi",
|
||||||
|
"kofi_title": "Support the project, development, and running costs on Ko-fi"
|
||||||
|
},
|
||||||
|
"languages": {
|
||||||
|
"de": "Deutsch",
|
||||||
|
"en": "English",
|
||||||
|
"da": "Dansk",
|
||||||
|
"sv": "Svenska",
|
||||||
|
"nb": "Norsk",
|
||||||
|
"fr": "French",
|
||||||
|
"es": "Spanish"
|
||||||
|
},
|
||||||
|
"dialog": {
|
||||||
|
"ok": "OK",
|
||||||
|
"yes": "Yes",
|
||||||
|
"no": "No"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"load_failed": "Could not load data.",
|
||||||
|
"save_failed": "Could not save changes.",
|
||||||
|
"delete_failed": "Could not delete.",
|
||||||
|
"export_failed": "Export failed."
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"unsaved_changes_title": "Unsaved changes",
|
"unsaved_changes_title": "Unsaved changes",
|
||||||
"unsaved_changes_message": "You have unsaved changes. Leave this page anyway? Your changes will be lost.",
|
"unsaved_changes_message": "You have unsaved changes. Leave this page anyway? Your changes will be lost.",
|
||||||
"unsaved_changes_leave": "Leave",
|
"unsaved_changes_stay": "Stay",
|
||||||
"unsaved_changes_stay": "Stay"
|
"unsaved_changes_save_leave": "Save & leave",
|
||||||
|
"unsaved_changes_discard": "Discard",
|
||||||
|
"unsaved_changes_leave": "Leave"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"vessel": "Vessel Profile",
|
"vessel": "Vessel Profile",
|
||||||
"crew": "Crew List",
|
"crew": "Crew",
|
||||||
"deviation": "Deviation Table",
|
"deviation": "Deviation Table",
|
||||||
"logs": "Logbook Entries",
|
"logs": "Logbook Entries",
|
||||||
"stats": "Statistics",
|
"stats": "Statistics",
|
||||||
"settings": "Settings"
|
"settings": "Settings",
|
||||||
|
"admin": "Admin"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"welcome": "Welcome to Kapteins Daagbok",
|
"welcome": "Welcome to Kapteins Daagbok",
|
||||||
@@ -61,7 +88,20 @@
|
|||||||
"enter_pin_placeholder": "Enter your PIN...",
|
"enter_pin_placeholder": "Enter your PIN...",
|
||||||
"decrypt_with_pin": "Decrypt",
|
"decrypt_with_pin": "Decrypt",
|
||||||
"use_recovery_instead": "Use recovery phrase instead",
|
"use_recovery_instead": "Use recovery phrase instead",
|
||||||
"error_incorrect_pin": "Incorrect PIN. Decryption failed."
|
"error_incorrect_pin": "Incorrect PIN. Decryption failed.",
|
||||||
|
"error_invalid_host": "Passkeys do not work on 127.0.0.1. Please open the app via localhost.",
|
||||||
|
"use_localhost_link": "Switch to localhost",
|
||||||
|
"error_passkey_cancelled": "Passkey sign-in was cancelled or timed out. Please try again.",
|
||||||
|
"error_invalid_rp_id": "Passkey domain mismatch (RP ID). For local dev use http://localhost:5173 with RP_ID=localhost in .env.",
|
||||||
|
"error_session_incomplete": "Sign-in incomplete. Please sign in with your passkey again.",
|
||||||
|
"restore_checking": "Checking session…",
|
||||||
|
"restore_title": "Restore session",
|
||||||
|
"restore_subtitle": "You are still signed in. Unlock your logbook with passkey or PIN.",
|
||||||
|
"restore_unlocking": "Unlocking…",
|
||||||
|
"restore_with_passkey": "Unlock with passkey ({{name}})",
|
||||||
|
"restore_with_pin": "Unlock with PIN",
|
||||||
|
"restore_pin_warning": "Enter your local PIN to unlock your logbook after reload.",
|
||||||
|
"restore_other_account": "Sign in with another account"
|
||||||
},
|
},
|
||||||
"pwa": {
|
"pwa": {
|
||||||
"title": "Install app",
|
"title": "Install app",
|
||||||
@@ -80,12 +120,18 @@
|
|||||||
"update_title": "Update available",
|
"update_title": "Update available",
|
||||||
"update_desc": "A new version of Kapteins Daagbok is ready. Reload to get the latest changes.",
|
"update_desc": "A new version of Kapteins Daagbok is ready. Reload to get the latest changes.",
|
||||||
"update_now": "Reload now",
|
"update_now": "Reload now",
|
||||||
"update_reloading": "Reloading…"
|
"update_reloading": "Reloading…",
|
||||||
|
"storage_persist_hint": "Your browser may delete offline data. Allow persistent storage to keep your logbook safe (browser settings or when prompted)."
|
||||||
},
|
},
|
||||||
"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",
|
||||||
|
"conflict_title": "Sync conflict",
|
||||||
|
"conflict_message": "{{count}} change(s) could not be synced (entry {{id}}…). Choose which version to keep.",
|
||||||
|
"conflict_use_server": "Use server version",
|
||||||
|
"conflict_keep_local": "Keep my version"
|
||||||
},
|
},
|
||||||
"vessel": {
|
"vessel": {
|
||||||
"title": "Vessel Master Data",
|
"title": "Vessel Master Data",
|
||||||
@@ -116,7 +162,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",
|
||||||
@@ -131,12 +183,20 @@
|
|||||||
"sign_cleared_skipper_re_sign_title": "Skipper signature removed",
|
"sign_cleared_skipper_re_sign_title": "Skipper signature removed",
|
||||||
"sign_cleared_skipper_re_sign": "The event log was changed. The skipper signature was removed. Please sign again.",
|
"sign_cleared_skipper_re_sign": "The event log was changed. The skipper signature was removed. Please sign again.",
|
||||||
"date": "Date",
|
"date": "Date",
|
||||||
"day_of_travel": "Day of Travel",
|
"day_of_travel": "Travel day",
|
||||||
|
"travel_day_number": "Travel day {{number}}",
|
||||||
"departure": "Departure Port (von)",
|
"departure": "Departure Port (von)",
|
||||||
"destination": "Destination Port (nach)",
|
"destination": "Destination Port (nach)",
|
||||||
"route": "Route / Journey",
|
"route": "Route / Journey",
|
||||||
|
"tanks": "Tanks",
|
||||||
|
"customize_columns": "Customize columns",
|
||||||
|
"column_selector_title": "Columns to Show",
|
||||||
"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",
|
||||||
@@ -179,20 +239,159 @@
|
|||||||
"saving": "Saving...",
|
"saving": "Saving...",
|
||||||
"saved": "Logbook page saved successfully!",
|
"saved": "Logbook page saved successfully!",
|
||||||
"loading": "Loading journal...",
|
"loading": "Loading journal...",
|
||||||
|
"view_mode_label": "View",
|
||||||
|
"view_list": "List",
|
||||||
|
"live_mode": "Live",
|
||||||
|
"live_title": "Live Journal",
|
||||||
|
"live_loading": "Loading live journal...",
|
||||||
|
"live_retry": "Try again",
|
||||||
|
"live_load_error": "Could not load live journal.",
|
||||||
|
"live_action_error": "Could not save entry.",
|
||||||
|
"live_open_editor": "Full editor",
|
||||||
|
"live_actions_label": "Quick actions",
|
||||||
|
"live_stream_label": "Event log",
|
||||||
|
"live_stream_title": "Journal",
|
||||||
|
"live_no_events": "No entries yet — tap an action.",
|
||||||
|
"live_motor_start": "Engine Start",
|
||||||
|
"live_motor_stop": "Engine Stop",
|
||||||
|
"live_cast_off": "Cast off",
|
||||||
|
"live_moor": "Moor",
|
||||||
|
"live_sails_btn": "Sails",
|
||||||
|
"live_sails_pick": "Select sails",
|
||||||
|
"live_sails_pick_hint": "Tap multiple sails (tap again to deselect), then log.",
|
||||||
|
"live_sails_selected": "Selected: {{sails}}",
|
||||||
|
"live_sails_confirm": "Log entry",
|
||||||
|
"live_sails_confirm_count": "Log entry ({{count}})",
|
||||||
|
"live_sails": "Sails: {{sails}}",
|
||||||
|
"live_position": "Position",
|
||||||
|
"live_position_coords": "Position {{lat}}, {{lng}}",
|
||||||
|
"live_position_manual_hint": "GPS unavailable. Enter latitude and longitude manually, or try again with the GPS button.",
|
||||||
|
"live_position_gps_loading": "Getting GPS position…",
|
||||||
|
"live_position_invalid": "Please enter valid coordinates (latitude −90…90, longitude −180…180).",
|
||||||
|
"live_position_lat_placeholder": "Latitude (Lat)",
|
||||||
|
"live_position_lng_placeholder": "Longitude (Lng)",
|
||||||
|
"live_photo_btn": "Photo (camera)",
|
||||||
|
"live_photo_capture_btn": "Capture",
|
||||||
|
"live_photo_save_btn": "Save",
|
||||||
|
"live_photo_retake_btn": "Retake",
|
||||||
|
"live_photo_capture_failed": "Capture failed. Please try again.",
|
||||||
|
"live_photo_open_camera_btn": "Open camera",
|
||||||
|
"live_photo_native_hint": "Take a photo with your device camera, then save it here.",
|
||||||
|
"live_photo_camera_starting": "Starting camera…",
|
||||||
|
"live_photo_camera_denied": "Camera access denied or unavailable.",
|
||||||
|
"live_photo_camera_unavailable": "Camera is not supported in this browser.",
|
||||||
|
"live_photo_no_camera": "No camera is available on this device.",
|
||||||
|
"live_photo_error": "Could not save photo.",
|
||||||
|
"live_photo_entry": "Photo: {{caption}}",
|
||||||
|
"live_photo_entry_plain": "Photo captured",
|
||||||
|
"live_undo_photo_hint": "Photo saved",
|
||||||
|
"live_voice_btn": "Voice memo",
|
||||||
|
"live_voice_hint": "Record a short voice memo (max. 60 seconds).",
|
||||||
|
"live_voice_record": "Start recording",
|
||||||
|
"live_voice_stop": "Stop recording",
|
||||||
|
"live_voice_recording": "Recording {{time}}",
|
||||||
|
"live_voice_save": "Save",
|
||||||
|
"live_voice_saving": "Saving…",
|
||||||
|
"live_voice_retake": "Record again",
|
||||||
|
"live_voice_mic_denied": "Microphone access denied or unavailable.",
|
||||||
|
"live_voice_record_failed": "Recording failed. Please try again.",
|
||||||
|
"live_voice_unavailable": "Voice memo unavailable",
|
||||||
|
"live_voice_too_large": "Recording is too large. Please record a shorter memo.",
|
||||||
|
"live_voice_error": "Could not save voice memo.",
|
||||||
|
"live_voice_entry": "Voice memo: {{caption}}",
|
||||||
|
"live_voice_entry_plain": "Voice memo",
|
||||||
|
"live_voice_caption_label": "Caption (optional)",
|
||||||
|
"live_voice_caption_placeholder": "e.g. radio call with harbour master",
|
||||||
|
"live_voice_transcribe_action": "Transcribe",
|
||||||
|
"live_voice_transcribing": "Transcribing…",
|
||||||
|
"live_voice_transcribe_failed": "Voice memo saved, but transcription failed.",
|
||||||
|
"live_undo_voice_hint": "Voice memo saved",
|
||||||
|
"live_comment_btn": "Comment",
|
||||||
|
"live_comment_placeholder": "Enter text…",
|
||||||
|
"live_comment_confirm": "Log entry",
|
||||||
|
"live_gps_error": "Could not determine GPS position.",
|
||||||
|
"live_gps_start_hint": "Always start your day's voyage with a position.",
|
||||||
|
"live_event_generic": "Event",
|
||||||
|
"live_weather_btn": "Weather",
|
||||||
|
"live_weather_owm_btn": "Fetch OpenWeatherMap weather",
|
||||||
|
"live_weather_owm_loading": "Loading weather…",
|
||||||
|
"live_weather_position_required": "Log a position first (Position button) to fetch OpenWeatherMap weather. The position must be at most 6 hours old.",
|
||||||
|
"live_weather_position_stale": "The last position is older than 6 hours. Log a new position before fetching weather.",
|
||||||
|
"live_wind_btn": "Wind",
|
||||||
|
"live_temp_btn": "Temp °C",
|
||||||
|
"live_pressure_btn": "Pressure",
|
||||||
|
"live_precip_btn": "Precipitation",
|
||||||
|
"live_sea_state_btn": "Sea state",
|
||||||
|
"live_visibility_btn": "Visibility",
|
||||||
|
"live_course_btn": "Course",
|
||||||
|
"live_fuel_btn": "+ Fuel",
|
||||||
|
"live_water_btn": "+ Water",
|
||||||
|
"live_wind_entry": "Wind {{value}}",
|
||||||
|
"live_temp_entry": "Temperature {{temp}} °C",
|
||||||
|
"live_pressure_entry": "Pressure {{value}} hPa",
|
||||||
|
"live_precip_entry": "Precipitation {{value}}",
|
||||||
|
"live_sea_state_entry": "Sea state {{value}}",
|
||||||
|
"live_visibility_entry": "Visibility {{value}}",
|
||||||
|
"live_course_entry": "Course {{course}}",
|
||||||
|
"live_fuel_entry": "Fuel +{{liters}} L",
|
||||||
|
"live_water_entry": "Water +{{liters}} L",
|
||||||
|
"live_auto_position": "Auto position",
|
||||||
|
"live_undo_hint": "Entry saved",
|
||||||
|
"live_undo_btn": "Undo",
|
||||||
|
"live_cancel": "Cancel",
|
||||||
|
"live_pressure_placeholder": "e.g. 1013",
|
||||||
|
"live_temp_placeholder": "e.g. 18",
|
||||||
|
"live_precip_placeholder": "e.g. light rain",
|
||||||
|
"live_sea_state_placeholder": "e.g. 3",
|
||||||
|
"live_visibility_placeholder": "e.g. 10 km",
|
||||||
|
"live_course_placeholder": "e.g. 245",
|
||||||
|
"live_fuel_placeholder": "Liters refilled",
|
||||||
|
"live_water_placeholder": "Liters refilled",
|
||||||
|
"live_sog_btn": "SOG",
|
||||||
|
"live_stw_btn": "STW",
|
||||||
|
"live_sog_entry": "SOG {{speed}} kn",
|
||||||
|
"live_stw_entry": "STW {{speed}} kn",
|
||||||
|
"live_sog_placeholder": "e.g. 5.2",
|
||||||
|
"live_stw_placeholder": "e.g. 4.8",
|
||||||
|
"live_sog_hint": "Speed over ground (kn) — prefilled from GPS when available.",
|
||||||
"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",
|
||||||
|
"event_creator": "Entered by",
|
||||||
"no_events": "No events logged for this travel day yet.",
|
"no_events": "No events logged for this travel day yet.",
|
||||||
"event_time": "Time",
|
"event_time": "Time",
|
||||||
"event_mgk": "MgK Course",
|
"event_mgk": "MgK Course",
|
||||||
"event_rwk": "RwK Course",
|
"event_rwk": "RwK Course",
|
||||||
|
"event_course_section": "Course",
|
||||||
|
"course_dial_hint": "Drag the ring or enter degrees",
|
||||||
|
"course_dial_step_label": "Step size",
|
||||||
|
"course_step_fine": "1°",
|
||||||
|
"course_step_medium": "5°",
|
||||||
|
"course_step_coarse": "10°",
|
||||||
|
"course_tab_mgk": "MgK",
|
||||||
|
"course_tab_rwk": "rwK",
|
||||||
|
"course_invalid": "Invalid course (0–360)",
|
||||||
|
"course_placeholder_degrees": "e.g. 180",
|
||||||
|
"course_placeholder_cardinal": "e.g. NW",
|
||||||
|
"compass_n": "N",
|
||||||
|
"compass_e": "E",
|
||||||
|
"compass_s": "S",
|
||||||
|
"compass_w": "W",
|
||||||
|
"wind_mode_cardinal": "Cardinal",
|
||||||
|
"wind_mode_degrees": "As degrees",
|
||||||
"event_wind_direction": "Wind Dir",
|
"event_wind_direction": "Wind Dir",
|
||||||
"event_wind_strength": "Wind Str",
|
"event_wind_strength": "Wind Str",
|
||||||
"event_sea_state": "Sea State",
|
"event_sea_state": "Sea State",
|
||||||
|
"event_visibility": "Visibility",
|
||||||
|
"event_visibility_placeholder": "e.g. 10 km",
|
||||||
|
"weather_slider_unset": "—",
|
||||||
|
"weather_slider_pressure": "{{value}} hPa",
|
||||||
|
"weather_slider_sea_state": "State {{value}}",
|
||||||
|
"weather_slider_heel": "{{value}}°",
|
||||||
"event_weather": "Weather",
|
"event_weather": "Weather",
|
||||||
"event_log": "Log (nm)",
|
"event_log": "Log (nm)",
|
||||||
"event_gps": "GPS Position",
|
"event_gps": "GPS Position",
|
||||||
@@ -200,11 +399,32 @@
|
|||||||
"event_location_placeholder": "e.g. Kiel",
|
"event_location_placeholder": "e.g. Kiel",
|
||||||
"event_remarks": "Remarks / Events",
|
"event_remarks": "Remarks / Events",
|
||||||
"gps_btn": "Get GPS Location",
|
"gps_btn": "Get GPS Location",
|
||||||
|
"gps_permission_denied": "Location access was denied. Allow it in your browser or device settings and try again.",
|
||||||
|
"gps_timeout": "GPS timed out. Try again outdoors with a clear view of the sky.",
|
||||||
|
"gps_position_unavailable": "No GPS signal available. Wait and retry, or enter coordinates manually.",
|
||||||
|
"gps_unavailable": "GPS is not supported by this browser or device.",
|
||||||
|
"gps_failed": "Could not determine GPS position.",
|
||||||
|
"gps_fallback_no_location": "GPS failed. Enter a place under Location / harbour, departure, or destination, or type coordinates manually.",
|
||||||
|
"gps_fallback_success": "Coordinates for \"{{location}}\" resolved from place name (not GPS).",
|
||||||
|
"gps_fallback_failed": "GPS and place-name lookup both failed. Please enter coordinates manually.",
|
||||||
|
"gps_quality_excellent": "Strong GPS reception (±{{accuracy}} m)",
|
||||||
|
"gps_quality_good": "Good GPS reception (±{{accuracy}} m)",
|
||||||
|
"gps_quality_fair": "Fair GPS reception (±{{accuracy}} m) — move outdoors for a better fix.",
|
||||||
|
"gps_quality_poor": "Weak GPS reception (±{{accuracy}} m) — likely few satellites. Retry outdoors or verify the position.",
|
||||||
|
"gps_quality_unknown": "GPS position applied (accuracy not reported by device).",
|
||||||
|
"gps_live_intro_title": "Location for Live Log",
|
||||||
|
"gps_live_intro_body": "The app needs your location for automatic position entries and the GPS button.\n\nTap “Allow location” and confirm in the next dialog. You can always enter a position manually via “Position”.",
|
||||||
|
"gps_live_intro_allow": "Allow location",
|
||||||
|
"gps_live_intro_later": "Later",
|
||||||
|
"gps_enable_in_settings_hint": "Location access is blocked. You can allow it later in your browser or device settings (site / app → Location).",
|
||||||
"weather_btn": "Fetch OpenWeatherMap Weather",
|
"weather_btn": "Fetch OpenWeatherMap Weather",
|
||||||
|
"weather_offline": "OpenWeatherMap requires an internet connection. You are currently offline.",
|
||||||
"event_wind_pressure": "Barometer (hPa)",
|
"event_wind_pressure": "Barometer (hPa)",
|
||||||
"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)",
|
||||||
@@ -212,10 +432,24 @@
|
|||||||
"share_csv": "Share CSV",
|
"share_csv": "Share CSV",
|
||||||
"export_pdf": "Download PDF",
|
"export_pdf": "Download PDF",
|
||||||
"exporting_pdf": "Generating PDF...",
|
"exporting_pdf": "Generating PDF...",
|
||||||
"photos_title": "Photo Attachments (E2E Encrypted)",
|
"ai_summary_title": "AI Summary",
|
||||||
|
"ai_summary_read_only": "Created by the skipper — read-only for crew.",
|
||||||
|
"ai_summary_empty": "No summary yet.",
|
||||||
|
"ai_summary_generate": "Generate summary",
|
||||||
|
"ai_summary_regenerate": "Regenerate",
|
||||||
|
"ai_summary_generating": "Generating…",
|
||||||
|
"ai_summary_attempts_remaining": "{{remaining}} of {{max}} attempts remaining",
|
||||||
|
"ai_summary_error": "AI summary failed. Please try again later.",
|
||||||
|
"ai_summary_error_no_key": "No OpenRouter API key configured on the server.",
|
||||||
|
"ai_summary_error_rate_limited": "Maximum number of generations reached for this travel day.",
|
||||||
|
"ai_summary_error_forbidden": "Only the skipper may generate AI summaries.",
|
||||||
|
"ai_summary_offline": "AI summary generation requires an internet connection. You are currently offline.",
|
||||||
|
"photos_title": "Photo Attachments",
|
||||||
"photo_caption_label": "Photo Caption / Label (Optional)",
|
"photo_caption_label": "Photo Caption / Label (Optional)",
|
||||||
"photo_caption_placeholder": "e.g. Setting sails near harbor entrance",
|
"photo_caption_placeholder": "e.g. Setting sails near harbor entrance",
|
||||||
"photo_btn": "Take Photo / Upload",
|
"photo_btn": "Take Photo / Upload",
|
||||||
|
"photo_camera_btn": "Take Photo",
|
||||||
|
"photo_gallery_btn": "Choose from Gallery",
|
||||||
"photo_processing": "Processing...",
|
"photo_processing": "Processing...",
|
||||||
"no_photos": "No photos attached to this journal entry yet.",
|
"no_photos": "No photos attached to this journal entry yet.",
|
||||||
"photo_delete_confirm": "Are you sure you want to permanently delete this photo?",
|
"photo_delete_confirm": "Are you sure you want to permanently delete this photo?",
|
||||||
@@ -236,6 +470,56 @@
|
|||||||
"track_map_end": "End",
|
"track_map_end": "End",
|
||||||
"track_map_speed_slow": "slow",
|
"track_map_speed_slow": "slow",
|
||||||
"track_map_speed_fast": "fast",
|
"track_map_speed_fast": "fast",
|
||||||
|
"nmea_import_title": "Import NMEA log",
|
||||||
|
"nmea_import_intro": "Upload a .nmea file from your onboard logger. The app suggests journal entries — you choose what to import.",
|
||||||
|
"nmea_import_btn": "Import NMEA",
|
||||||
|
"nmea_file_label": "NMEA file",
|
||||||
|
"nmea_stats": "{{lines}} sentences parsed · types: {{types}}",
|
||||||
|
"nmea_warn_no_position": "No position sentences found — track and GPS fields may stay empty.",
|
||||||
|
"nmea_warn_duplicate_file": "This NMEA file has already been imported. Importing the same file again will add duplicate journal entries.",
|
||||||
|
"nmea_mode_label": "Generate journal entries",
|
||||||
|
"nmea_mode_interval": "By time interval",
|
||||||
|
"nmea_mode_change": "On significant change",
|
||||||
|
"nmea_mode_both": "Both (merge)",
|
||||||
|
"nmea_interval_label": "Interval (minutes)",
|
||||||
|
"nmea_import_track": "Import GPS track from NMEA",
|
||||||
|
"nmea_preview": "Preview",
|
||||||
|
"nmea_preview_hint": "{{count}} suggested journal entries",
|
||||||
|
"nmea_select_all": "Select all",
|
||||||
|
"nmea_select_none": "Select none",
|
||||||
|
"nmea_source_interval": "Interval",
|
||||||
|
"nmea_source_change": "Event",
|
||||||
|
"nmea_apply": "Apply to journal",
|
||||||
|
"nmea_back": "Back",
|
||||||
|
"nmea_cancel": "Cancel",
|
||||||
|
"nmea_archive_question": "Archive raw log locally? (This device only, not synced.)",
|
||||||
|
"nmea_archive_keep": "Archive",
|
||||||
|
"nmea_archive_discard": "Discard",
|
||||||
|
"nmea_archive_stored": "NMEA archived: {{name}}",
|
||||||
|
"nmea_archive_delete_confirm": "Delete archived NMEA log from this device?",
|
||||||
|
"nmea_error_no_samples": "No usable NMEA sentences in the file.",
|
||||||
|
"nmea_error_parse": "Could not read NMEA file.",
|
||||||
|
"nmea_error_read": "Could not read file.",
|
||||||
|
"nmea_error_no_file": "Please choose an NMEA file first.",
|
||||||
|
"nmea_error_no_selection": "Please select at least one journal entry.",
|
||||||
|
"nmea_remark_interval": "NMEA interval",
|
||||||
|
"nmea_remark_uncertain": "uncertain",
|
||||||
|
"nmea_remark_depth": "Depth {{depth}} m",
|
||||||
|
"nmea_change_course": "Course change {{from}}° → {{to}}°",
|
||||||
|
"nmea_change_wind": "Wind {{from}}° → {{to}}°",
|
||||||
|
"nmea_change_wind_speed": "Wind {{from}} → {{to}} kn",
|
||||||
|
"nmea_change_pressure": "Pressure {{from}} → {{to}} hPa",
|
||||||
|
"nmea_change_depth": "Depth {{from}} → {{to}} m",
|
||||||
|
"nmea_change_engine_start": "Engine on ({{rpm}} rpm)",
|
||||||
|
"nmea_change_engine_stop": "Engine off",
|
||||||
|
"nmea_change_autopilot_on": "Autopilot on",
|
||||||
|
"nmea_change_autopilot_off": "Autopilot off",
|
||||||
|
"nmea_change_gps_lost": "GPS position lost",
|
||||||
|
"nmea_change_gps_regained": "GPS position restored",
|
||||||
|
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
|
||||||
|
"nmea_change_departure": "Departure / underway",
|
||||||
|
"nmea_change_anchor": "Anchored / stop",
|
||||||
|
"nmea_change_speed": "Speed {{from}} → {{to}} kn",
|
||||||
"track_map_error": "Could not load map.",
|
"track_map_error": "Could not load map.",
|
||||||
"exporting": "Exporting...",
|
"exporting": "Exporting...",
|
||||||
"share_unsupported": "Web sharing is not supported on this device. File downloaded instead.",
|
"share_unsupported": "Web sharing is not supported on this device. File downloaded instead.",
|
||||||
@@ -255,9 +539,12 @@
|
|||||||
"new_logbook_placeholder": "Logbook or Yacht Name",
|
"new_logbook_placeholder": "Logbook or Yacht Name",
|
||||||
"logout": "Logout",
|
"logout": "Logout",
|
||||||
"logged_in_as": "Signed in as {{name}}",
|
"logged_in_as": "Signed in as {{name}}",
|
||||||
"delete_confirm": "Are you sure you want to permanently delete this logbook? All local data and server copies will be destroyed.\n\nTip: Create a backup first under Settings → Backup & restore (.daagbok.json) if you may need the data later.",
|
"delete_confirm": "Are you sure you want to permanently delete this logbook? All local data and server copies will be destroyed.\n\nTip: Create a backup first under Settings → Backup & restore (.daagbok) if you may need the data later.",
|
||||||
"no_logbooks": "No logbooks found. Create your first logbook to begin!",
|
"no_logbooks": "No logbooks found. Create your first logbook to begin!",
|
||||||
"loading": "Loading logbooks...",
|
"loading": "Loading logbooks...",
|
||||||
|
"travel_days_count_zero": "No travel days",
|
||||||
|
"travel_days_count_one": "1 travel day",
|
||||||
|
"travel_days_count_other": "{{count}} travel days",
|
||||||
"status_synced": "Synced",
|
"status_synced": "Synced",
|
||||||
"status_local": "Local Cache Only",
|
"status_local": "Local Cache Only",
|
||||||
"delete_btn": "Delete logbook",
|
"delete_btn": "Delete logbook",
|
||||||
@@ -269,7 +556,217 @@
|
|||||||
"role_crew": "Crew access",
|
"role_crew": "Crew access",
|
||||||
"role_crew_hint": "Invited logbook — you can collaborate and sign as crew",
|
"role_crew_hint": "Invited logbook — you can collaborate and sign as crew",
|
||||||
"role_read": "Read only",
|
"role_read": "Read only",
|
||||||
"role_read_hint": "Shared logbook — view only, no editing"
|
"role_read_hint": "Shared logbook — view only, no editing",
|
||||||
|
"open_profile": "Open profile for {{name}}",
|
||||||
|
"open_logbook": "Open logbook “{{title}}”",
|
||||||
|
"edit_title": "Rename Logbook",
|
||||||
|
"edit_placeholder": "New name of the logbook",
|
||||||
|
"edit_success": "Logbook renamed successfully",
|
||||||
|
"edit_btn": "Rename",
|
||||||
|
"filter_label": "Filter logbooks",
|
||||||
|
"filter_placeholder": "Name, year, date, crew or vessel …",
|
||||||
|
"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": {
|
||||||
|
"title": "User profile",
|
||||||
|
"subtitle": "Account, passkeys and statistics for {{name}}",
|
||||||
|
"back": "Back to dashboard",
|
||||||
|
"loading": "Loading profile…",
|
||||||
|
"load_error": "Could not load profile.",
|
||||||
|
"copy_failed": "Copy failed.",
|
||||||
|
"processing": "Processing…",
|
||||||
|
"identity_title": "Account identity",
|
||||||
|
"username": "Username",
|
||||||
|
"user_id": "User ID",
|
||||||
|
"copy_user_id": "Copy user ID",
|
||||||
|
"account_since": "Account since",
|
||||||
|
"prf_status": "Passkey key derivation (PRF)",
|
||||||
|
"prf_active": "Active",
|
||||||
|
"prf_inactive": "Not configured",
|
||||||
|
"passkeys_title": "Passkeys",
|
||||||
|
"passkeys_desc": "Register a passkey on each device you use. This helps when switching platforms or browsers.",
|
||||||
|
"passkeys_empty": "No passkeys found.",
|
||||||
|
"add_passkey_btn": "Add new passkey",
|
||||||
|
"add_passkey_success": "Passkey added successfully.",
|
||||||
|
"add_passkey_failed": "Could not add passkey.",
|
||||||
|
"remove_passkey_btn": "Remove passkey",
|
||||||
|
"remove_passkey_last_title": "Last passkey",
|
||||||
|
"remove_passkey_last_desc": "The only passkey cannot be removed without losing access to your account. To delete the account entirely, use the danger zone at the bottom of this page.",
|
||||||
|
"remove_passkey_failed": "Could not remove passkey.",
|
||||||
|
"remove_passkey_confirm_title": "Remove passkey?",
|
||||||
|
"remove_passkey_confirm_desc": "This device will no longer be able to sign in with this passkey.",
|
||||||
|
"remove_passkey_confirm_yes": "Remove",
|
||||||
|
"remove_passkey_confirm_no": "Cancel",
|
||||||
|
"pin_title": "Local PIN",
|
||||||
|
"pin_status": "Status",
|
||||||
|
"pin_active": "Active on this device",
|
||||||
|
"pin_inactive": "Not configured",
|
||||||
|
"pin_confirm_label": "Confirm PIN",
|
||||||
|
"pin_confirm_placeholder": "Re-enter PIN",
|
||||||
|
"pin_set_btn": "Set PIN",
|
||||||
|
"pin_change_btn": "Change PIN",
|
||||||
|
"pin_remove_btn": "Remove PIN",
|
||||||
|
"pin_saved": "PIN saved.",
|
||||||
|
"pin_save_failed": "Could not save PIN.",
|
||||||
|
"pin_mismatch": "PIN entries do not match.",
|
||||||
|
"pin_length_error": "PIN must be at least 4 characters.",
|
||||||
|
"pin_no_session": "Session expired — please sign in again.",
|
||||||
|
"remove_pin_confirm_title": "Remove PIN?",
|
||||||
|
"remove_pin_confirm_desc": "You will need to sign in on this device with passkey or recovery phrase again.",
|
||||||
|
"remove_pin_confirm_yes": "Remove PIN",
|
||||||
|
"remove_pin_confirm_no": "Cancel",
|
||||||
|
"security_title": "Security checklist",
|
||||||
|
"security_desc": "Overview of the most important protections for your account.",
|
||||||
|
"security_passkeys_ok": "At least one passkey registered",
|
||||||
|
"security_passkeys_missing": "No passkey registered",
|
||||||
|
"security_prf_ok": "PRF key derivation active",
|
||||||
|
"security_prf_missing": "PRF not configured",
|
||||||
|
"security_pin_ok": "Local PIN on this device",
|
||||||
|
"security_pin_missing": "No local PIN",
|
||||||
|
"security_recovery_ok": "Recovery phrase configured",
|
||||||
|
"security_recovery_hint": "The 12 words were shown at registration. Store them offline and separately from this device. You can create a new phrase below — the old one will then be invalidated.",
|
||||||
|
"recovery_rotate_btn": "Create new recovery phrase",
|
||||||
|
"recovery_rotate_confirm_title": "Create new recovery phrase?",
|
||||||
|
"recovery_rotate_confirm_desc": "Your previous 12-word phrase will be invalidated immediately. Make sure you can store the new phrase securely before continuing.",
|
||||||
|
"recovery_rotate_confirm_yes": "Create new phrase",
|
||||||
|
"recovery_rotate_confirm_no": "Cancel",
|
||||||
|
"recovery_rotate_new_warning": "IMPORTANT: Write down these 12 words and store them offline. Your previous recovery phrase is no longer valid.",
|
||||||
|
"recovery_rotate_failed": "Could not create a new recovery phrase.",
|
||||||
|
"recovery_rotate_no_session": "Encryption session expired — please sign out and sign in again, then retry.",
|
||||||
|
"device_title": "This device",
|
||||||
|
"device_desc": "Local cache, sync status, and quick login on this browser.",
|
||||||
|
"device_sync_pending": "{{count}} pending sync items",
|
||||||
|
"device_sync_ok": "All local changes synced",
|
||||||
|
"device_remembered": "Account saved for quick login on this device",
|
||||||
|
"device_not_remembered": "Account not in the quick-login list",
|
||||||
|
"device_forget_btn": "Forget account on this device",
|
||||||
|
"device_forget_confirm_title": "Remove quick login?",
|
||||||
|
"device_forget_confirm_desc": "The account will be removed from the quick-login list on this device. Your session and local logbooks stay on this device.",
|
||||||
|
"device_forget_confirm_yes": "Remove",
|
||||||
|
"device_forget_confirm_no": "Cancel",
|
||||||
|
"passkey_label": "Name for new passkey (optional)",
|
||||||
|
"passkey_label_placeholder": "e.g. MacBook, iPhone",
|
||||||
|
"passkey_rename_btn": "Save name",
|
||||||
|
"passkey_rename_success": "Passkey name saved.",
|
||||||
|
"passkey_rename_failed": "Could not save passkey name.",
|
||||||
|
"passkey_unnamed": "Unnamed passkey",
|
||||||
|
"stats_title": "Statistics",
|
||||||
|
"stats_subtitle": "Across all your logbooks on this device",
|
||||||
|
"stats_logbooks": "Logbooks",
|
||||||
|
"stats_account_since": "Account since",
|
||||||
|
"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.",
|
||||||
|
"ai_title": "AI Features & Privacy",
|
||||||
|
"ai_desc": "Authorize artificial intelligence integrations for your logbooks.",
|
||||||
|
"ai_help": "Enabling AI features allows the app to summarize travel days and transcribe recorded voice memos. To process these requests, raw voice data and travel logs are sent securely on-the-fly to OpenRouter. No data is stored permanently by the AI model.\n\nThese cloud resources cost money to run; if you enjoy using them, please consider supporting the project voluntarily with a donation via the Ko-fi link in the footer to keep them free and sustainable for everyone.",
|
||||||
|
"ai_enable_label": "Enable transcription and travel day summaries",
|
||||||
|
"ai_unauthorized_alert_title": "AI Features Not Authorized",
|
||||||
|
"ai_unauthorized_alert_desc": "To use transcription or travel day summaries, please authorize the data transmission to OpenRouter in your User Profile under 'AI Features & Privacy'.",
|
||||||
|
"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.",
|
||||||
|
"sections": {
|
||||||
|
"account": "Account & settings",
|
||||||
|
"fleet": "Fleet & crew",
|
||||||
|
"security": "Security & device",
|
||||||
|
"stats": "Statistics",
|
||||||
|
"danger": "Danger zone"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"vessel_pool": {
|
||||||
|
"title": "Vessel fleet",
|
||||||
|
"section_title": "Your vessels",
|
||||||
|
"subtitle": "Maintain all vessels for your logbooks here. Select the active vessel per logbook from this list.",
|
||||||
|
"loading": "Loading vessel fleet…",
|
||||||
|
"add_vessel": "Add vessel",
|
||||||
|
"edit_vessel": "Edit vessel",
|
||||||
|
"no_vessels": "No vessels in the pool yet.",
|
||||||
|
"delete_confirm": "Remove this vessel from the fleet?",
|
||||||
|
"max_vessels": "Maximum of 20 vessels in the pool reached."
|
||||||
|
},
|
||||||
|
"logbook_vessel": {
|
||||||
|
"title": "Vessel for this logbook",
|
||||||
|
"subtitle": "Choose the vessel for this logbook. Travel days use sails and tank data from the selected vessel.",
|
||||||
|
"active_vessel": "Vessel for this logbook",
|
||||||
|
"no_vessels_in_pool": "No vessel in the fleet — add one in your user profile first.",
|
||||||
|
"no_vessel": "No vessel selected",
|
||||||
|
"unnamed": "Unnamed",
|
||||||
|
"save": "Save vessel",
|
||||||
|
"saved": "Logbook vessel saved.",
|
||||||
|
"selection_only_hint": "You see the vessel chosen by the owner (shared logbook).",
|
||||||
|
"manage_in_profile": "Manage vessels in user profile"
|
||||||
|
},
|
||||||
|
"person_pool": {
|
||||||
|
"title": "Core Crew & skippers",
|
||||||
|
"subtitle": "Maintain your person pool here — skippers and crew for all logbooks. Select active crew per logbook and travel day from this pool.",
|
||||||
|
"loading": "Loading person pool…",
|
||||||
|
"skippers_section": "Skippers",
|
||||||
|
"crew_section": "Core Crew",
|
||||||
|
"add_skipper": "Add skipper",
|
||||||
|
"add_crew": "Add crew member",
|
||||||
|
"edit_skipper": "Edit skipper",
|
||||||
|
"no_skippers": "No skippers in the pool yet.",
|
||||||
|
"no_crew": "No crew members in the pool yet.",
|
||||||
|
"delete_confirm": "Remove this person from the pool?"
|
||||||
|
},
|
||||||
|
"logbook_crew": {
|
||||||
|
"title": "Crew for this logbook",
|
||||||
|
"subtitle": "Choose skipper and crew for this logbook. New travel days inherit this selection by default.",
|
||||||
|
"loading": "Loading crew…",
|
||||||
|
"active_skipper": "Skipper for this logbook",
|
||||||
|
"active_crew": "Crew for this logbook",
|
||||||
|
"no_skippers_in_pool": "No skipper in the pool — add one in your user profile first.",
|
||||||
|
"no_crew_in_pool": "No crew in the pool — add members in your user profile first.",
|
||||||
|
"no_skipper": "No skipper selected",
|
||||||
|
"unnamed": "Unnamed",
|
||||||
|
"save": "Save crew",
|
||||||
|
"saved": "Logbook crew saved.",
|
||||||
|
"selection_only_hint": "You see the crew set by the owner (shared logbook)."
|
||||||
|
},
|
||||||
|
"entry_crew": {
|
||||||
|
"title": "Crew on this travel day",
|
||||||
|
"subtitle": "May differ from the logbook default. Following days inherit from the previous day.",
|
||||||
|
"day_skipper": "Skipper on this day",
|
||||||
|
"day_crew": "Crew on this day",
|
||||||
|
"no_skipper": "No skipper selected",
|
||||||
|
"no_crew": "No crew selected"
|
||||||
},
|
},
|
||||||
"crew": {
|
"crew": {
|
||||||
"title": "Skipper & Crew Profiles",
|
"title": "Skipper & Crew Profiles",
|
||||||
@@ -279,7 +776,7 @@
|
|||||||
"add_crew": "Add Crew Member",
|
"add_crew": "Add Crew Member",
|
||||||
"edit_crew": "Edit Crew Member",
|
"edit_crew": "Edit Crew Member",
|
||||||
"no_crew": "No crew members added yet.",
|
"no_crew": "No crew members added yet.",
|
||||||
"max_crew": "Maximum of 5 crew members reached.",
|
"max_crew": "Maximum of 12 crew members in the pool reached.",
|
||||||
"name": "Full Name",
|
"name": "Full Name",
|
||||||
"address": "Address",
|
"address": "Address",
|
||||||
"birthdate": "Date of Birth",
|
"birthdate": "Date of Birth",
|
||||||
@@ -306,35 +803,25 @@
|
|||||||
"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_unauthorized": "Failed to fetch weather. The API key is invalid or unauthorized.",
|
||||||
|
"weather_not_found": "Failed to fetch weather. The specified location or coordinates were not found.",
|
||||||
|
"weather_bad_request": "Failed to fetch weather. No location or GPS position was specified.",
|
||||||
"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_enable": "Enable Public Link",
|
"share_enable": "Enable Public Link",
|
||||||
"share_copied": "Link copied!",
|
"share_copied": "Link copied!",
|
||||||
"share_copy_btn": "Copy Link",
|
"share_copy_btn": "Copy Link",
|
||||||
|
"link_qr_hint": "Scan this QR code with your phone",
|
||||||
|
"link_qr_alt": "QR code for the link",
|
||||||
"danger_zone_title": "Danger Zone",
|
"danger_zone_title": "Danger Zone",
|
||||||
"danger_zone_desc": "Deleting your account will permanently delete all your passkeys, logbooks, vessel data, crew profiles, travel logs, and E2E keys. This action cannot be undone.",
|
"danger_zone_desc": "Deleting your account will permanently delete all your passkeys, logbooks, vessel data, crew profiles, travel logs, and E2E keys. This action cannot be undone.",
|
||||||
"delete_account_btn": "Permanently Delete Account",
|
"delete_account_btn": "Permanently Delete Account",
|
||||||
@@ -343,22 +830,18 @@
|
|||||||
"delete_account_confirm_yes": "Yes, Delete Account and All Data",
|
"delete_account_confirm_yes": "Yes, Delete Account and All Data",
|
||||||
"delete_account_confirm_no": "Cancel",
|
"delete_account_confirm_no": "Cancel",
|
||||||
"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) 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, voice memos, 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",
|
||||||
"backup_export_desc": "Downloads all local data as a .daagbok.json file. Keep the file and passphrase separate and secure.",
|
"backup_export_desc": "Downloads all local data as a compressed .daagbok archive. Keep the file and passphrase separate and secure.",
|
||||||
"backup_restore_title": "Restore backup",
|
"backup_restore_title": "Restore backup",
|
||||||
"backup_restore_desc": "Restores a backup into your current account — including after registering a new account.",
|
"backup_restore_desc": "Restores a backup into your current account — including after registering a new account.",
|
||||||
"backup_passphrase": "Backup passphrase",
|
"backup_passphrase": "Backup passphrase",
|
||||||
@@ -370,7 +853,13 @@
|
|||||||
"backup_export_btn": "Download backup",
|
"backup_export_btn": "Download backup",
|
||||||
"backup_exporting": "Creating backup…",
|
"backup_exporting": "Creating backup…",
|
||||||
"backup_export_success": "Backup created ({{count}} travel days).",
|
"backup_export_success": "Backup created ({{count}} travel days).",
|
||||||
"backup_file_label": "Backup file (.daagbok.json)",
|
"backup_file_label": "Backup file (.daagbok)",
|
||||||
|
"backup_export_progress": "Packing files {{current}} / {{total}}…",
|
||||||
|
"backup_invalid_archive": "The file is not a valid backup archive.",
|
||||||
|
"backup_version_unsupported": "Legacy backup format (v1). Please use a current .daagbok backup.",
|
||||||
|
"backup_import_size_confirm": "This backup is about {{size}} uncompressed. Restore may take longer and use significant memory. Continue?",
|
||||||
|
"backup_stat_voice": "{{count}} voice memos",
|
||||||
|
"backup_stat_size": "Approx. {{size}} uncompressed",
|
||||||
"backup_preview_btn": "Verify contents",
|
"backup_preview_btn": "Verify contents",
|
||||||
"backup_previewing": "Verifying…",
|
"backup_previewing": "Verifying…",
|
||||||
"backup_restore_btn": "Restore",
|
"backup_restore_btn": "Restore",
|
||||||
@@ -418,6 +907,7 @@
|
|||||||
"category_general": "General",
|
"category_general": "General",
|
||||||
"category_bug": "Bug report",
|
"category_bug": "Bug report",
|
||||||
"category_feature": "Feature request",
|
"category_feature": "Feature request",
|
||||||
|
"category_translation": "Translation error",
|
||||||
"contact_label": "Email (optional)",
|
"contact_label": "Email (optional)",
|
||||||
"contact_placeholder": "your@email.example",
|
"contact_placeholder": "your@email.example",
|
||||||
"message_label": "Message",
|
"message_label": "Message",
|
||||||
@@ -506,7 +996,13 @@
|
|||||||
"unit_l": "L",
|
"unit_l": "L",
|
||||||
"day_label": "Day {{day}}",
|
"day_label": "Day {{day}}",
|
||||||
"account_logbooks": "Logbooks overview",
|
"account_logbooks": "Logbooks overview",
|
||||||
"col_logbook": "Logbook"
|
"col_logbook": "Logbook",
|
||||||
|
"event_series_title": "Event series",
|
||||||
|
"event_series_hint": "Chronological values from the event log.",
|
||||||
|
"event_series_pressure": "Barometric pressure",
|
||||||
|
"event_series_wind": "Wind",
|
||||||
|
"event_series_motor": "Engine",
|
||||||
|
"event_series_empty": "No entries yet."
|
||||||
},
|
},
|
||||||
"tour": {
|
"tour": {
|
||||||
"skip": "Skip tour",
|
"skip": "Skip tour",
|
||||||
@@ -521,7 +1017,7 @@
|
|||||||
},
|
},
|
||||||
"welcome_public": {
|
"welcome_public": {
|
||||||
"title": "Welcome aboard!",
|
"title": "Welcome aboard!",
|
||||||
"body": "Explore our demo logbook with three travel days in Kiel Bay — no account required. This short tour shows vessel data, crew, and log entries."
|
"body": "Explore our demo logbook with three travel days in Kiel Bay — no account required. The tour covers log entries and vessel and crew selection for this logbook. Manage your fleet and core crew later in your user profile."
|
||||||
},
|
},
|
||||||
"nav_logs": {
|
"nav_logs": {
|
||||||
"title": "Log entries",
|
"title": "Log entries",
|
||||||
@@ -540,12 +1036,20 @@
|
|||||||
"body": "Upload GPX files or view saved routes on the map – including distance and speed stats."
|
"body": "Upload GPX files or view saved routes on the map – including distance and speed stats."
|
||||||
},
|
},
|
||||||
"nav_vessel": {
|
"nav_vessel": {
|
||||||
"title": "Vessel data",
|
"title": "Vessel for logbook",
|
||||||
"body": "Enter your yacht's name, dimensions, and technical details – fill once, use on every travel day."
|
"body": "Choose a vessel from your fleet for this logbook. Manage vessels in your user profile under Fleet & crew."
|
||||||
},
|
},
|
||||||
"nav_crew": {
|
"profile_vessel_pool": {
|
||||||
"title": "Crew list",
|
"title": "Vessel fleet",
|
||||||
"body": "Manage crew members and assign them to travel days later."
|
"body": "In your user profile you add all your vessels — charter yachts, your own boat, etc. Then pick the right vessel per logbook."
|
||||||
|
},
|
||||||
|
"profile_crew_pool": {
|
||||||
|
"title": "Core Crew & skippers",
|
||||||
|
"body": "In your user profile you maintain a person pool — multiple skippers (e.g. charter) and crew for all logbooks."
|
||||||
|
},
|
||||||
|
"nav_logbook_crew": {
|
||||||
|
"title": "Crew per logbook",
|
||||||
|
"body": "Pick skipper and crew from the pool for this logbook. Travel days inherit this selection by default."
|
||||||
},
|
},
|
||||||
"nav_stats": {
|
"nav_stats": {
|
||||||
"title": "Statistics dashboard",
|
"title": "Statistics dashboard",
|
||||||
@@ -555,11 +1059,25 @@
|
|||||||
"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!"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"seo": {
|
||||||
|
"title": "Kapteins Daagbok – Free Digital Yacht Logbook (Ad-Free)",
|
||||||
|
"description": "Free, ad-free digital yacht logbook with end-to-end encryption and Passkey sign-in. Document travel days, GPS tracks, crew and vessel data securely — offline-capable PWA.",
|
||||||
|
"keywords": "yacht logbook, ship logbook, sailing log, maritime logbook, passkey, E2E encryption, GPS track, free, ad-free, offline PWA",
|
||||||
|
"ogImageAlt": "Kapteins Daagbok logo"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+65
-97
@@ -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,70 @@ 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 {
|
/* Scrollbars — auf Touch-Geräten breiter und besser sichtbar */
|
||||||
font-size: 56px;
|
:root {
|
||||||
letter-spacing: -1.68px;
|
--app-scrollbar-size: 10px;
|
||||||
margin: 32px 0;
|
}
|
||||||
@media (max-width: 1024px) {
|
|
||||||
font-size: 36px;
|
@media (hover: none), (pointer: coarse), (max-width: 768px) {
|
||||||
margin: 20px 0;
|
:root {
|
||||||
|
--app-scrollbar-size: 14px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
h2 {
|
|
||||||
font-size: 24px;
|
html {
|
||||||
line-height: 118%;
|
scrollbar-width: auto;
|
||||||
letter-spacing: -0.24px;
|
scrollbar-color: var(--app-accent-light) var(--app-surface-inset);
|
||||||
margin: 0 0 8px;
|
-webkit-overflow-scrolling: touch;
|
||||||
@media (max-width: 1024px) {
|
}
|
||||||
font-size: 20px;
|
|
||||||
|
html::-webkit-scrollbar,
|
||||||
|
body::-webkit-scrollbar,
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
width: var(--app-scrollbar-size);
|
||||||
|
height: var(--app-scrollbar-size);
|
||||||
|
}
|
||||||
|
|
||||||
|
html::-webkit-scrollbar-track,
|
||||||
|
body::-webkit-scrollbar-track,
|
||||||
|
*::-webkit-scrollbar-track {
|
||||||
|
background: var(--app-surface-inset);
|
||||||
|
border-radius: calc(var(--app-scrollbar-size) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
html::-webkit-scrollbar-thumb,
|
||||||
|
body::-webkit-scrollbar-thumb,
|
||||||
|
*::-webkit-scrollbar-thumb {
|
||||||
|
background: color-mix(in srgb, var(--app-accent-light) 55%, transparent);
|
||||||
|
border-radius: calc(var(--app-scrollbar-size) / 2);
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html::-webkit-scrollbar-thumb:hover,
|
||||||
|
body::-webkit-scrollbar-thumb:hover,
|
||||||
|
*::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: color-mix(in srgb, var(--app-accent-light) 80%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: none), (pointer: coarse), (max-width: 768px) {
|
||||||
|
html::-webkit-scrollbar-thumb,
|
||||||
|
body::-webkit-scrollbar-thumb,
|
||||||
|
*::-webkit-scrollbar-thumb {
|
||||||
|
background: color-mix(in srgb, var(--app-accent-light) 70%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
html::-webkit-scrollbar-thumb:active,
|
||||||
|
body::-webkit-scrollbar-thumb:active,
|
||||||
|
*::-webkit-scrollbar-thumb:active {
|
||||||
|
background: var(--app-accent-light);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|||||||
+97
-7
@@ -3,14 +3,104 @@ 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 from './App.tsx'
|
import './App.css'
|
||||||
import './i18n'
|
import './i18n'
|
||||||
|
import App from './App.tsx'
|
||||||
import { applyAppearanceToDocument } from './services/appearance.ts'
|
import { applyAppearanceToDocument } from './services/appearance.ts'
|
||||||
|
import { flushPendingPwaBootEvents } from './services/analytics.ts'
|
||||||
|
import {
|
||||||
|
installStaleAssetRecovery,
|
||||||
|
markReloadAttempt,
|
||||||
|
reconcileVersionOnStartup
|
||||||
|
} from './services/pwaStartup.ts'
|
||||||
|
import { redirectToPasskeyCompatibleHostIfNeeded } from './utils/passkeyHost.ts'
|
||||||
|
|
||||||
applyAppearanceToDocument()
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__KDB_MAIN_MODULE_LOADED?: boolean
|
||||||
|
__KDB_APP_BOOTSTRAPPED?: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
window.__KDB_MAIN_MODULE_LOADED = true
|
||||||
<StrictMode>
|
|
||||||
<App />
|
/** Stale PWA precache on localhost can shadow Vite dev modules. */
|
||||||
</StrictMode>,
|
async function clearDevServiceWorkerCaches(): Promise<void> {
|
||||||
)
|
if (!import.meta.env.DEV || !('serviceWorker' in navigator)) return
|
||||||
|
const regs = await navigator.serviceWorker.getRegistrations()
|
||||||
|
await Promise.all(regs.map((r) => r.unregister()))
|
||||||
|
if ('caches' in window) {
|
||||||
|
const keys = await caches.keys()
|
||||||
|
await Promise.all(keys.map((k) => caches.delete(k)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBootstrapError(message: string): void {
|
||||||
|
const root = document.getElementById('root')
|
||||||
|
if (!root) return
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="auth-screen">
|
||||||
|
<div class="auth-card glass" role="alert" style="max-width:420px">
|
||||||
|
<h2 style="margin-top:0">Kapteins Daagbok</h2>
|
||||||
|
<p style="color:var(--app-text-muted);line-height:1.5">${message}</p>
|
||||||
|
<button type="button" class="btn primary" style="width:100%;margin-top:16px" onclick="location.reload()">
|
||||||
|
Neu laden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootstrap(): Promise<void> {
|
||||||
|
if (redirectToPasskeyCompatibleHostIfNeeded()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
applyAppearanceToDocument()
|
||||||
|
installStaleAssetRecovery()
|
||||||
|
flushPendingPwaBootEvents()
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
flushPendingPwaBootEvents()
|
||||||
|
}, { once: true })
|
||||||
|
await clearDevServiceWorkerCaches()
|
||||||
|
|
||||||
|
const startupResult = await reconcileVersionOnStartup()
|
||||||
|
if (startupResult === 'reload') {
|
||||||
|
markReloadAttempt()
|
||||||
|
window.location.reload()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (startupResult === 'recovered') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('serviceWorker' in navigator && !import.meta.env.DEV) {
|
||||||
|
navigator.serviceWorker
|
||||||
|
.register('/sw.js', { scope: '/' })
|
||||||
|
.then((reg) => {
|
||||||
|
console.log('Service Worker registered successfully with scope:', reg.scope)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('Service Worker registration failed:', err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootEl = document.getElementById('root')
|
||||||
|
if (!rootEl) {
|
||||||
|
throw new Error('Missing #root element')
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(rootEl).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
|
window.__KDB_APP_BOOTSTRAPPED = true
|
||||||
|
}
|
||||||
|
|
||||||
|
void bootstrap().catch((err) => {
|
||||||
|
console.error('App bootstrap failed:', err)
|
||||||
|
renderBootstrapError(
|
||||||
|
'Die App konnte nicht gestartet werden. Bitte neu laden oder die App vollständig beenden und erneut öffnen.',
|
||||||
|
)
|
||||||
|
window.__KDB_APP_BOOTSTRAPPED = false
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { ApiError, apiJson } from './api.js'
|
||||||
|
|
||||||
|
const ADMIN_BASE = '/api/admin'
|
||||||
|
|
||||||
|
export interface AdminMe {
|
||||||
|
isAdmin: boolean
|
||||||
|
userId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminSummary {
|
||||||
|
totalUsers: number
|
||||||
|
totalLogbooks: number
|
||||||
|
totalPhotos: number
|
||||||
|
totalVoiceMemos: number
|
||||||
|
totalGpsTracks: number
|
||||||
|
totalCollaborations: number
|
||||||
|
totalInvitations: number
|
||||||
|
aiSummaryEntries: number
|
||||||
|
dbSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminTimeBucket = 'day' | 'week' | 'month'
|
||||||
|
|
||||||
|
export interface AdminTimeSeriesPoint {
|
||||||
|
date: string
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminTimeSeriesMetric {
|
||||||
|
metric: string
|
||||||
|
points: AdminTimeSeriesPoint[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminTimeSeriesResponse {
|
||||||
|
bucket: AdminTimeBucket
|
||||||
|
windowDays: number
|
||||||
|
series: AdminTimeSeriesMetric[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminMe(): Promise<AdminMe> {
|
||||||
|
return await apiJson<AdminMe>(`${ADMIN_BASE}/me`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true only for users listed in server ADMIN_USER_IDS. */
|
||||||
|
export async function checkAdminAccess(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await fetchAdminMe()
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminSummary(): Promise<AdminSummary> {
|
||||||
|
return await apiJson<AdminSummary>(`${ADMIN_BASE}/summary`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminTimeSeries(
|
||||||
|
params: { bucket?: AdminTimeBucket; windowDays?: number } = {}
|
||||||
|
): Promise<AdminTimeSeriesResponse> {
|
||||||
|
const search = new URLSearchParams()
|
||||||
|
if (params.bucket) {
|
||||||
|
search.set('bucket', params.bucket)
|
||||||
|
}
|
||||||
|
if (params.windowDays && Number.isFinite(params.windowDays)) {
|
||||||
|
search.set('window', String(params.windowDays))
|
||||||
|
}
|
||||||
|
const query = search.toString()
|
||||||
|
const url = query ? `${ADMIN_BASE}/timeseries?${query}` : `${ADMIN_BASE}/timeseries`
|
||||||
|
return await apiJson<AdminTimeSeriesResponse>(url)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { buildTravelDayContext } from './aiSummary.js'
|
||||||
|
import type { LogEventPayload } from '../utils/logEntryPayload.js'
|
||||||
|
|
||||||
|
const t = ((key: string, opts?: Record<string, unknown>) => {
|
||||||
|
if (key === 'logs.live_motor_start') return 'Motor started'
|
||||||
|
if (key === 'logs.live_event_generic') return 'Event'
|
||||||
|
if (opts && 'course' in opts) return `Course ${opts.course}`
|
||||||
|
return key
|
||||||
|
}) as any
|
||||||
|
|
||||||
|
describe('buildTravelDayContext', () => {
|
||||||
|
it('includes route metadata and formatted events', () => {
|
||||||
|
const events: LogEventPayload[] = [
|
||||||
|
{
|
||||||
|
time: '09:00',
|
||||||
|
mgk: '180',
|
||||||
|
rwk: '',
|
||||||
|
windPressure: '',
|
||||||
|
windDirection: '',
|
||||||
|
windStrength: '',
|
||||||
|
seaState: '',
|
||||||
|
visibility: '',
|
||||||
|
weatherIcon: '',
|
||||||
|
current: '',
|
||||||
|
heel: '',
|
||||||
|
sailsOrMotor: 'Genua',
|
||||||
|
logReading: '',
|
||||||
|
distance: '',
|
||||||
|
gpsLat: '',
|
||||||
|
gpsLng: '',
|
||||||
|
remarks: '__live:motor_start'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const context = buildTravelDayContext(
|
||||||
|
{
|
||||||
|
date: '2026-06-03',
|
||||||
|
dayOfTravel: '5',
|
||||||
|
departure: 'Kiel',
|
||||||
|
destination: 'Copenhagen',
|
||||||
|
freshwater: { morning: 100, refilled: 0, evening: 80, consumption: 20 },
|
||||||
|
fuel: { morning: 50, refilled: 10, evening: 40, consumption: 20 },
|
||||||
|
greywaterLevel: 0,
|
||||||
|
trackDistanceNm: 42.5,
|
||||||
|
motorHours: 3.5,
|
||||||
|
events
|
||||||
|
},
|
||||||
|
t
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(context.departure).toBe('Kiel')
|
||||||
|
expect(context.destination).toBe('Copenhagen')
|
||||||
|
expect(context.trackDistanceNm).toBe(42.5)
|
||||||
|
expect(context.motorHours).toBe(3.5)
|
||||||
|
expect(context.events).toHaveLength(1)
|
||||||
|
expect(context.events[0].summary).toBe('Motor started')
|
||||||
|
expect(context.events[0].sailsOrMotor).toBe('Genua')
|
||||||
|
expect(context.greywater).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import type { TFunction } from 'i18next'
|
||||||
|
import { apiFetch } from './api.js'
|
||||||
|
import { formatEventSummary } from '../utils/formatEventSummary.js'
|
||||||
|
import { sortLogEventsByTime, type LogEventPayload } from '../utils/logEntryPayload.js'
|
||||||
|
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
|
||||||
|
|
||||||
|
export class TravelDaySummaryApiError extends Error {
|
||||||
|
code: 'NO_KEY' | 'FORBIDDEN' | 'RATE_LIMITED' | 'OFFLINE' | 'REQUEST_FAILED'
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
code: 'NO_KEY' | 'FORBIDDEN' | 'RATE_LIMITED' | 'OFFLINE' | 'REQUEST_FAILED' = 'REQUEST_FAILED'
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'TravelDaySummaryApiError'
|
||||||
|
this.code = code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TravelDaySummaryContext {
|
||||||
|
date: string
|
||||||
|
dayOfTravel: string
|
||||||
|
departure: string
|
||||||
|
destination: string
|
||||||
|
trackDistanceNm?: number
|
||||||
|
trackSpeedMaxKn?: number
|
||||||
|
trackSpeedAvgKn?: number
|
||||||
|
motorHours?: number
|
||||||
|
freshwater?: {
|
||||||
|
morning: number
|
||||||
|
refilled: number
|
||||||
|
evening: number
|
||||||
|
consumption: number
|
||||||
|
}
|
||||||
|
fuel?: {
|
||||||
|
morning: number
|
||||||
|
refilled: number
|
||||||
|
evening: number
|
||||||
|
consumption: number
|
||||||
|
}
|
||||||
|
greywater?: { level: number }
|
||||||
|
events: Array<{
|
||||||
|
time: string
|
||||||
|
summary: string
|
||||||
|
sailsOrMotor?: string
|
||||||
|
mgk?: string
|
||||||
|
windDirection?: string
|
||||||
|
windStrength?: string
|
||||||
|
windPressure?: string
|
||||||
|
seaState?: string
|
||||||
|
visibility?: string
|
||||||
|
distance?: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TravelDaySummaryInput {
|
||||||
|
date: string
|
||||||
|
dayOfTravel: string
|
||||||
|
departure: string
|
||||||
|
destination: string
|
||||||
|
trackDistanceNm?: number
|
||||||
|
trackSpeedMaxKn?: number
|
||||||
|
trackSpeedAvgKn?: number
|
||||||
|
motorHours?: number
|
||||||
|
freshwater: { morning: number; refilled: number; evening: number; consumption: number }
|
||||||
|
fuel: { morning: number; refilled: number; evening: number; consumption: number }
|
||||||
|
greywaterLevel?: number
|
||||||
|
events: LogEventPayload[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUMMARY_FETCH_TIMEOUT_MS = 90_000
|
||||||
|
|
||||||
|
export function buildTravelDayContext(
|
||||||
|
input: TravelDaySummaryInput,
|
||||||
|
t: TFunction
|
||||||
|
): TravelDaySummaryContext {
|
||||||
|
const context: TravelDaySummaryContext = {
|
||||||
|
date: input.date,
|
||||||
|
dayOfTravel: input.dayOfTravel,
|
||||||
|
departure: input.departure,
|
||||||
|
destination: input.destination,
|
||||||
|
freshwater: input.freshwater,
|
||||||
|
fuel: input.fuel,
|
||||||
|
events: sortLogEventsByTime(input.events).map((event) => ({
|
||||||
|
time: event.time,
|
||||||
|
summary: formatEventSummary(event, t),
|
||||||
|
...(event.sailsOrMotor ? { sailsOrMotor: event.sailsOrMotor } : {}),
|
||||||
|
...(event.mgk ? { mgk: event.mgk } : {}),
|
||||||
|
...(event.windDirection ? { windDirection: event.windDirection } : {}),
|
||||||
|
...(event.windStrength ? { windStrength: event.windStrength } : {}),
|
||||||
|
...(event.windPressure ? { windPressure: event.windPressure } : {}),
|
||||||
|
...(event.seaState ? { seaState: event.seaState } : {}),
|
||||||
|
...(event.visibility ? { visibility: event.visibility } : {}),
|
||||||
|
...(event.distance ? { distance: event.distance } : {})
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.trackDistanceNm !== undefined) context.trackDistanceNm = input.trackDistanceNm
|
||||||
|
if (input.trackSpeedMaxKn !== undefined) context.trackSpeedMaxKn = input.trackSpeedMaxKn
|
||||||
|
if (input.trackSpeedAvgKn !== undefined) context.trackSpeedAvgKn = input.trackSpeedAvgKn
|
||||||
|
if (input.motorHours !== undefined && input.motorHours > 0) context.motorHours = input.motorHours
|
||||||
|
if (input.greywaterLevel !== undefined && input.greywaterLevel > 0) {
|
||||||
|
context.greywater = { level: input.greywaterLevel }
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapApiError(status: number, data: unknown): TravelDaySummaryApiError {
|
||||||
|
const code =
|
||||||
|
typeof data === 'object' && data !== null && 'code' in data
|
||||||
|
? String((data as { code?: string }).code)
|
||||||
|
: ''
|
||||||
|
|
||||||
|
if (status === 503 || code === 'NO_KEY') {
|
||||||
|
return new TravelDaySummaryApiError('No OpenRouter API key configured', 'NO_KEY')
|
||||||
|
}
|
||||||
|
if (status === 403) {
|
||||||
|
return new TravelDaySummaryApiError('Forbidden', 'FORBIDDEN')
|
||||||
|
}
|
||||||
|
if (status === 429 || code === 'RATE_LIMITED') {
|
||||||
|
return new TravelDaySummaryApiError('Rate limit exceeded', 'RATE_LIMITED')
|
||||||
|
}
|
||||||
|
|
||||||
|
const message =
|
||||||
|
typeof data === 'object' && data !== null && 'error' in data && typeof (data as { error: unknown }).error === 'string'
|
||||||
|
? (data as { error: string }).error
|
||||||
|
: 'Request failed'
|
||||||
|
return new TravelDaySummaryApiError(message, 'REQUEST_FAILED')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTravelDaySummaryUsage(
|
||||||
|
logbookId: string,
|
||||||
|
entryId: string
|
||||||
|
): Promise<{ remainingAttempts: number; maxAttempts: number }> {
|
||||||
|
const params = new URLSearchParams({ logbookId, entryId })
|
||||||
|
const res = await apiFetch(`/api/ai/usage?${params.toString()}`)
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
if (!res.ok) throw mapApiError(res.status, data)
|
||||||
|
return data as { remainingAttempts: number; maxAttempts: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateTravelDaySummary(params: {
|
||||||
|
logbookId: string
|
||||||
|
entryId: string
|
||||||
|
language: string
|
||||||
|
context: TravelDaySummaryContext
|
||||||
|
}): Promise<{ summary: string; remainingAttempts: number; maxAttempts: number }> {
|
||||||
|
if (!navigator.onLine) {
|
||||||
|
throw new TravelDaySummaryApiError('Offline', 'OFFLINE')
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = window.setTimeout(() => controller.abort(), SUMMARY_FETCH_TIMEOUT_MS)
|
||||||
|
|
||||||
|
let res: Response
|
||||||
|
try {
|
||||||
|
res = await apiFetch('/api/ai/summary', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(params),
|
||||||
|
signal: controller.signal
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||||
|
throw new TravelDaySummaryApiError('AI summary request timed out')
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
if (!res.ok) throw mapApiError(res.status, data)
|
||||||
|
|
||||||
|
trackPlausibleEvent(PlausibleEvents.AI_SUMMARY_GENERATED)
|
||||||
|
|
||||||
|
return data as { summary: string; remainingAttempts: number; maxAttempts: number }
|
||||||
|
}
|
||||||
@@ -25,12 +25,45 @@ export const PlausibleEvents = {
|
|||||||
DEMO_OPENED: 'Demo Opened',
|
DEMO_OPENED: 'Demo Opened',
|
||||||
PUSH_ENABLED: 'Push Enabled',
|
PUSH_ENABLED: 'Push Enabled',
|
||||||
PUSH_DISABLED: 'Push Disabled',
|
PUSH_DISABLED: 'Push Disabled',
|
||||||
FOOTER_LINK_CLICKED: 'Footer Link Clicked'
|
FOOTER_LINK_CLICKED: 'Footer Link Clicked',
|
||||||
|
KOFI_LINK_CLICKED: 'Ko-fi Link Clicked',
|
||||||
|
PROFILE_OPENED: 'Profile Opened',
|
||||||
|
PASSKEY_ADDED: 'Passkey Added',
|
||||||
|
PASSKEY_REMOVED: 'Passkey Removed',
|
||||||
|
PASSKEY_RENAMED: 'Passkey Renamed',
|
||||||
|
LAST_PASSKEY_REMOVE_HINTED: 'Last Passkey Remove Hinted',
|
||||||
|
LOCAL_PIN_SET: 'Local PIN Set',
|
||||||
|
LOCAL_PIN_REMOVED: 'Local PIN Removed',
|
||||||
|
DEVICE_FORGOTTEN: 'Device Forgotten',
|
||||||
|
RECOVERY_ROTATED: 'Recovery Rotated',
|
||||||
|
LANGUAGE_CHANGED: 'Language Changed',
|
||||||
|
NMEA_IMPORTED: 'NMEA Imported',
|
||||||
|
NMEA_UPLOADED: 'NMEA Uploaded',
|
||||||
|
LIVE_LOG_OPENED: 'Live Log Opened',
|
||||||
|
LIVE_LOG_EVENT_LOGGED: 'Live Log Event Logged',
|
||||||
|
VOICE_MEMO_UPLOADED: 'Voice Memo Uploaded',
|
||||||
|
VOICE_MEMO_TRANSCRIBED: 'Voice Memo Transcribed',
|
||||||
|
OWM_WEATHER_FETCHED: 'OWM Weather Fetched',
|
||||||
|
AI_SUMMARY_GENERATED: 'AI Summary Generated',
|
||||||
|
PWA_BOOT_WATCHDOG_SOFT: 'PWA Boot Watchdog Soft',
|
||||||
|
PWA_BOOT_WATCHDOG_HARD: 'PWA Boot Watchdog Hard',
|
||||||
|
PWA_BOOT_WATCHDOG_FALLBACK: 'PWA Boot Watchdog Fallback',
|
||||||
|
PWA_BOOT_WATCHDOG_MANUAL_REPAIR: 'PWA Boot Watchdog Manual Repair'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
/** Where a successful OpenWeatherMap API call originated (no coordinates or place names). */
|
||||||
|
export type OwmAnalyticsSource = 'live_log' | 'entry_editor' | 'entry_editor_gps_lookup'
|
||||||
|
|
||||||
export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents]
|
export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents]
|
||||||
|
|
||||||
export type PlausibleEventProps = Record<string, string | number | boolean>
|
export type PlausibleEventProps = Record<string, string | number | boolean>
|
||||||
|
type PendingPwaBootEvent = {
|
||||||
|
name: PlausibleEventName
|
||||||
|
props?: PlausibleEventProps
|
||||||
|
ts?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const PWA_BOOT_PENDING_EVENTS_KEY = 'pwa_boot_pending_events'
|
||||||
|
|
||||||
export function trackPlausibleEvent(name: PlausibleEventName, props?: PlausibleEventProps): void {
|
export function trackPlausibleEvent(name: PlausibleEventName, props?: PlausibleEventProps): void {
|
||||||
if (typeof window.plausible !== 'function') return
|
if (typeof window.plausible !== 'function') return
|
||||||
@@ -40,3 +73,52 @@ export function trackPlausibleEvent(name: PlausibleEventName, props?: PlausibleE
|
|||||||
}
|
}
|
||||||
window.plausible(name)
|
window.plausible(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function flushPendingPwaBootEvents(): number {
|
||||||
|
if (typeof window.plausible !== 'function') return 0
|
||||||
|
|
||||||
|
let raw: string | null = null
|
||||||
|
try {
|
||||||
|
raw = sessionStorage.getItem(PWA_BOOT_PENDING_EVENTS_KEY)
|
||||||
|
} catch {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if (!raw) return 0
|
||||||
|
|
||||||
|
let pending: PendingPwaBootEvent[]
|
||||||
|
try {
|
||||||
|
pending = JSON.parse(raw) as PendingPwaBootEvent[]
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem(PWA_BOOT_PENDING_EVENTS_KEY)
|
||||||
|
} catch {
|
||||||
|
/* ignore storage errors */
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(pending) || pending.length === 0) {
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem(PWA_BOOT_PENDING_EVENTS_KEY)
|
||||||
|
} catch {
|
||||||
|
/* ignore storage errors */
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const event of pending) {
|
||||||
|
if (!event || typeof event.name !== 'string') continue
|
||||||
|
if (event.props && Object.keys(event.props).length > 0) {
|
||||||
|
window.plausible(event.name, { props: event.props })
|
||||||
|
} else {
|
||||||
|
window.plausible(event.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem(PWA_BOOT_PENDING_EVENTS_KEY)
|
||||||
|
} catch {
|
||||||
|
/* ignore storage errors */
|
||||||
|
}
|
||||||
|
return pending.length
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,22 +10,43 @@ export class ApiError extends Error {
|
|||||||
|
|
||||||
export async function apiFetch(
|
export async function apiFetch(
|
||||||
input: string,
|
input: string,
|
||||||
init: RequestInit = {}
|
init: RequestInit = {},
|
||||||
|
timeoutMs = 15000
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const headers = new Headers(init.headers)
|
const headers = new Headers(init.headers)
|
||||||
if (init.body !== undefined && !headers.has('Content-Type')) {
|
if (init.body !== undefined && !headers.has('Content-Type')) {
|
||||||
headers.set('Content-Type', 'application/json')
|
headers.set('Content-Type', 'application/json')
|
||||||
}
|
}
|
||||||
|
|
||||||
return fetch(input, {
|
const controller = new AbortController()
|
||||||
...init,
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
|
||||||
headers,
|
|
||||||
credentials: 'include'
|
if (init.signal) {
|
||||||
})
|
if (init.signal.aborted) {
|
||||||
|
controller.abort()
|
||||||
|
} else {
|
||||||
|
init.signal.addEventListener('abort', () => controller.abort())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await fetch(input, {
|
||||||
|
...init,
|
||||||
|
headers,
|
||||||
|
credentials: 'include',
|
||||||
|
signal: controller.signal
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiJson<T>(input: string, init: RequestInit = {}): Promise<T> {
|
export async function apiJson<T>(
|
||||||
const res = await apiFetch(input, init)
|
input: string,
|
||||||
|
init: RequestInit = {},
|
||||||
|
timeoutMs = 15000
|
||||||
|
): Promise<T> {
|
||||||
|
const res = await apiFetch(input, init, timeoutMs)
|
||||||
const data = await res.json().catch(() => ({}))
|
const data = await res.json().catch(() => ({}))
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const message =
|
const message =
|
||||||
|
|||||||
@@ -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,100 @@
|
|||||||
|
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',
|
||||||
|
aiAuthorized: false,
|
||||||
|
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',
|
||||||
|
aiAuthorized: true,
|
||||||
|
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(localStorage.getItem(`user_pref_ai_authorized_${USER_ID}`)).toBe('true')
|
||||||
|
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', aiAuthorized: false, persisted: false })
|
||||||
|
.mockResolvedValueOnce({ theme: 'material', colorScheme: 'auto', aiAuthorized: false, 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', aiAuthorized: false })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saveAppearancePrefsToServer skips when not authenticated', async () => {
|
||||||
|
await saveAppearancePrefsToServer('ocean', 'light', true)
|
||||||
|
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',
|
||||||
|
aiAuthorized: false,
|
||||||
|
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,87 @@
|
|||||||
|
import { apiJson } from './api.js'
|
||||||
|
import { notifyAppearanceChanged } from './appearance.js'
|
||||||
|
import {
|
||||||
|
getActiveUserId,
|
||||||
|
getColorSchemePreference,
|
||||||
|
getThemePreference,
|
||||||
|
setColorSchemePreference,
|
||||||
|
setThemePreference,
|
||||||
|
getAiAuthorized,
|
||||||
|
setAiAuthorized
|
||||||
|
} from './userPreferences.js'
|
||||||
|
|
||||||
|
const API_BASE = '/api/auth/appearance-prefs'
|
||||||
|
|
||||||
|
export interface AppearancePrefs {
|
||||||
|
theme: string
|
||||||
|
colorScheme: string
|
||||||
|
aiAuthorized: boolean
|
||||||
|
persisted: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasLocalAppearancePrefs(userId: string): boolean {
|
||||||
|
return (
|
||||||
|
localStorage.getItem(`user_pref_theme_${userId}`) != null ||
|
||||||
|
localStorage.getItem(`user_pref_color_scheme_${userId}`) != null ||
|
||||||
|
localStorage.getItem(`user_pref_ai_authorized_${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', aiAuthorized: false, persisted: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiJson<AppearancePrefs>(API_BASE)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveAppearancePrefsToServer(
|
||||||
|
theme: string,
|
||||||
|
colorScheme: string,
|
||||||
|
aiAuthorized: boolean,
|
||||||
|
userId?: string | null
|
||||||
|
): Promise<void> {
|
||||||
|
if (!resolveSyncedUserId(userId)) return
|
||||||
|
|
||||||
|
await apiJson<AppearancePrefs>(API_BASE, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ theme, colorScheme, aiAuthorized })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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)
|
||||||
|
setAiAuthorized(id, server.aiAuthorized)
|
||||||
|
} else if (hasLocalAppearancePrefs(id)) {
|
||||||
|
await saveAppearancePrefsToServer(
|
||||||
|
getThemePreference(id),
|
||||||
|
getColorSchemePreference(id),
|
||||||
|
getAiAuthorized(id),
|
||||||
|
id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to sync appearance preferences:', err)
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyAppearanceChanged()
|
||||||
|
}
|
||||||
+180
-3
@@ -12,6 +12,7 @@ import { clearLogbookKeysCache } from './logbookKeys.js'
|
|||||||
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
|
import { PlausibleEvents, trackPlausibleEvent } from './analytics.js'
|
||||||
import { db } from './db.js'
|
import { db } from './db.js'
|
||||||
import { apiFetch, apiJson } from './api.js'
|
import { apiFetch, apiJson } from './api.js'
|
||||||
|
import { isWebAuthnUserAbortError } from '../utils/passkeyHost.js'
|
||||||
|
|
||||||
const API_BASE = '/api/auth'
|
const API_BASE = '/api/auth'
|
||||||
|
|
||||||
@@ -33,13 +34,45 @@ export function setActiveMasterKey(key: ArrayBuffer | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function checkServerSession(): Promise<{ authenticated: boolean; userId?: string }> {
|
export async function checkServerSession(): Promise<{ authenticated: boolean; userId?: string }> {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = window.setTimeout(() => controller.abort(), 8_000)
|
||||||
try {
|
try {
|
||||||
return await apiJson<{ authenticated: boolean; userId?: string }>(`${API_BASE}/session`)
|
return await apiJson<{ authenticated: boolean; userId?: string }>(`${API_BASE}/session`, {
|
||||||
|
signal: controller.signal
|
||||||
|
})
|
||||||
} catch {
|
} catch {
|
||||||
return { authenticated: false }
|
return { authenticated: false }
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(timeoutId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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 {
|
||||||
|
return hasUnlockedLocalCrypto() && !!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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Username to use when re-unlocking after reload (active account or sole remembered user). */
|
||||||
|
export function resolveRestoreUsername(): string | null {
|
||||||
|
const stored = localStorage.getItem('active_username')
|
||||||
|
if (stored) return stored
|
||||||
|
const known = getKnownUsernames()
|
||||||
|
if (known.length === 1) return known[0]
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
export async function reauthWithPasskey(): Promise<boolean> {
|
export async function reauthWithPasskey(): Promise<boolean> {
|
||||||
const options = await apiJson<any>(`${API_BASE}/reauth-options`, {
|
const options = await apiJson<any>(`${API_BASE}/reauth-options`, {
|
||||||
method: 'POST'
|
method: 'POST'
|
||||||
@@ -338,7 +371,11 @@ export async function loginUser(username?: string): Promise<LoginResult> {
|
|||||||
const prfRequested = !!options.extensions?.prf
|
const prfRequested = !!options.extensions?.prf
|
||||||
try {
|
try {
|
||||||
credentialResponse = await startAuthentication({ optionsJSON: options })
|
credentialResponse = await startAuthentication({ optionsJSON: options })
|
||||||
} catch (err: any) {
|
} catch (err: unknown) {
|
||||||
|
// User cancelled or timed out — never open a second platform prompt.
|
||||||
|
if (isWebAuthnUserAbortError(err)) {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
if (prfRequested) {
|
if (prfRequested) {
|
||||||
console.warn('Passkey authentication with PRF extension failed, retrying without PRF:', err)
|
console.warn('Passkey authentication with PRF extension failed, retrying without PRF:', err)
|
||||||
if (options.extensions) {
|
if (options.extensions) {
|
||||||
@@ -528,9 +565,15 @@ export async function deleteAccount(): Promise<boolean> {
|
|||||||
db.deviations.clear(),
|
db.deviations.clear(),
|
||||||
db.entries.clear(),
|
db.entries.clear(),
|
||||||
db.photos.clear(),
|
db.photos.clear(),
|
||||||
|
db.voiceMemos.clear(),
|
||||||
db.gpsTracks.clear(),
|
db.gpsTracks.clear(),
|
||||||
db.syncQueue.clear(),
|
db.syncQueue.clear(),
|
||||||
db.logbookKeys.clear()
|
db.logbookKeys.clear(),
|
||||||
|
db.personPool.clear(),
|
||||||
|
db.vesselPool.clear(),
|
||||||
|
db.logbookCrewSelections.clear(),
|
||||||
|
db.logbookVesselSelections.clear(),
|
||||||
|
db.userSyncQueue.clear()
|
||||||
])
|
])
|
||||||
|
|
||||||
// Wipe localStorage and session variables
|
// Wipe localStorage and session variables
|
||||||
@@ -543,3 +586,137 @@ export async function deleteAccount(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserProfileCredential {
|
||||||
|
id: string
|
||||||
|
label: string | null
|
||||||
|
credentialIdPreview: string
|
||||||
|
transports: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserProfile {
|
||||||
|
userId: string
|
||||||
|
username: string
|
||||||
|
createdAt: string
|
||||||
|
hasPrfEncryption: boolean
|
||||||
|
credentials: UserProfileCredential[]
|
||||||
|
serverMeta: {
|
||||||
|
ownedLogbookCount: number
|
||||||
|
collaborationCount: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchUserProfile(): Promise<UserProfile> {
|
||||||
|
return apiJson<UserProfile>(`${API_BASE}/profile`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enrollPrfFromMasterKey(masterKey: ArrayBuffer, prfFirst: ArrayBuffer): Promise<void> {
|
||||||
|
const prfKey = await deriveKeyFromPrf(prfFirst)
|
||||||
|
const encryptedPrf = await encryptBuffer(masterKey, prfKey)
|
||||||
|
await apiJson(`${API_BASE}/enroll-prf`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
encryptedMasterKeyPrf: encryptedPrf.ciphertext,
|
||||||
|
encryptedMasterKeyPrfIv: encryptedPrf.iv,
|
||||||
|
encryptedMasterKeyPrfTag: encryptedPrf.tag
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addPasskey(label?: string): Promise<void> {
|
||||||
|
await reauthWithPasskey()
|
||||||
|
|
||||||
|
const options = await apiJson<any>(`${API_BASE}/add-credential-options`, {
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!options.extensions) {
|
||||||
|
options.extensions = {}
|
||||||
|
}
|
||||||
|
options.extensions.prf = { eval: { first: PRF_SALT.buffer } }
|
||||||
|
|
||||||
|
let credentialResponse
|
||||||
|
const prfRequested = !!options.extensions?.prf
|
||||||
|
try {
|
||||||
|
credentialResponse = await startRegistration({ optionsJSON: options })
|
||||||
|
} catch (err: any) {
|
||||||
|
const isOptionError = err.name === 'NotSupportedError' ||
|
||||||
|
err.message?.toLowerCase().includes('options') ||
|
||||||
|
err.message?.toLowerCase().includes('process') ||
|
||||||
|
err.message?.toLowerCase().includes('unable to')
|
||||||
|
if (prfRequested && isOptionError) {
|
||||||
|
console.warn('Add passkey with PRF extension failed, retrying without PRF:', err)
|
||||||
|
if (options.extensions) {
|
||||||
|
delete options.extensions.prf
|
||||||
|
}
|
||||||
|
credentialResponse = await startRegistration({ optionsJSON: options })
|
||||||
|
} else {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiJson(`${API_BASE}/add-credential-verify`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
credentialResponse,
|
||||||
|
challenge: options.challenge,
|
||||||
|
...(label?.trim() ? { label: label.trim() } : {})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const masterKey = getActiveMasterKey()
|
||||||
|
const prfFirstBuffer = extractPrfFirst(credentialResponse.clientExtensionResults || {})
|
||||||
|
if (masterKey && prfFirstBuffer) {
|
||||||
|
try {
|
||||||
|
await enrollPrfFromMasterKey(masterKey, prfFirstBuffer)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to enroll PRF after adding passkey:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removePasskey(credentialDbId: string): Promise<void> {
|
||||||
|
await reauthWithPasskey()
|
||||||
|
|
||||||
|
const res = await apiFetch(`${API_BASE}/credentials/${credentialDbId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}))
|
||||||
|
throw new Error(body.error || 'Failed to remove passkey')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renamePasskey(credentialDbId: string, label: string): Promise<void> {
|
||||||
|
await reauthWithPasskey()
|
||||||
|
|
||||||
|
await apiJson(`${API_BASE}/credentials/${credentialDbId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ label })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rotateRecoveryPhrase(): Promise<string> {
|
||||||
|
const masterKey = getActiveMasterKey()
|
||||||
|
if (!masterKey) {
|
||||||
|
throw new Error('NO_ACTIVE_MASTER_KEY')
|
||||||
|
}
|
||||||
|
|
||||||
|
await reauthWithPasskey()
|
||||||
|
|
||||||
|
const recoveryPhrase = generateRecoveryPhrase()
|
||||||
|
const recoveryKey = await deriveKeyFromPhrase(recoveryPhrase)
|
||||||
|
const encryptedRecovery = await encryptBuffer(masterKey, recoveryKey)
|
||||||
|
|
||||||
|
await apiJson(`${API_BASE}/rotate-recovery`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
encryptedMasterKeyRec: encryptedRecovery.ciphertext,
|
||||||
|
encryptedMasterKeyRecIv: encryptedRecovery.iv,
|
||||||
|
encryptedMasterKeyRecTag: encryptedRecovery.tag
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return recoveryPhrase
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
hasUnlockedLocalCrypto,
|
||||||
|
hasUnlockedLocalSession,
|
||||||
|
resolveRestoreUsername,
|
||||||
|
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('resolveRestoreUsername', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prefers active_username from storage', () => {
|
||||||
|
localStorage.setItem('active_username', 'captain')
|
||||||
|
localStorage.setItem('daagbox_known_users', JSON.stringify(['other']))
|
||||||
|
expect(resolveRestoreUsername()).toBe('captain')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to a single remembered user', () => {
|
||||||
|
localStorage.setItem('daagbox_known_users', JSON.stringify(['solo']))
|
||||||
|
expect(resolveRestoreUsername()).toBe('solo')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when multiple users and no active username', () => {
|
||||||
|
localStorage.setItem('daagbox_known_users', JSON.stringify(['alpha', 'beta']))
|
||||||
|
expect(resolveRestoreUsername()).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { db } from './db.js'
|
||||||
|
import { getActiveMasterKey } from './auth.js'
|
||||||
|
import { decryptJson, encryptJson } from './crypto.js'
|
||||||
|
import { getLogbookKey } from './logbookKeys.js'
|
||||||
|
import type { PersonData } from '../types/person.js'
|
||||||
|
import { buildLogbookCrewSelection, pickActiveSkipperId } from '../utils/personSnapshots.js'
|
||||||
|
import { entryCrewFromLogbookSelection } from '../utils/personSnapshots.js'
|
||||||
|
import { saveLogbookCrewSelection } from './logbookCrewSelection.js'
|
||||||
|
const MIGRATION_FLAG = 'crew_pool_migration_v1_done'
|
||||||
|
|
||||||
|
export async function migrateLegacyCrewToPoolIfNeeded(): Promise<void> {
|
||||||
|
const userId = localStorage.getItem('active_userid')
|
||||||
|
if (!userId || localStorage.getItem(MIGRATION_FLAG) === userId) return
|
||||||
|
|
||||||
|
const masterKey = getActiveMasterKey()
|
||||||
|
if (!masterKey) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ownedLogbooks = await db.logbooks.filter((lb) => lb.isShared !== 1).toArray()
|
||||||
|
const poolByLegacyKey = new Map<string, string>()
|
||||||
|
const poolData = new Map<string, PersonData>()
|
||||||
|
|
||||||
|
for (const logbook of ownedLogbooks) {
|
||||||
|
const logbookKey = (await getLogbookKey(logbook.id)) || masterKey
|
||||||
|
const legacyCrews = await db.crews.where({ logbookId: logbook.id }).toArray()
|
||||||
|
|
||||||
|
const legacyIds: { skipperIds: string[]; crewIds: string[] } = {
|
||||||
|
skipperIds: [],
|
||||||
|
crewIds: []
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const record of legacyCrews) {
|
||||||
|
const data = (await decryptJson(record.encryptedData, record.iv, record.tag, logbookKey)) as
|
||||||
|
| PersonData
|
||||||
|
| null
|
||||||
|
if (!data) continue
|
||||||
|
|
||||||
|
const role = record.payloadId === 'skipper' ? 'skipper' : 'crew'
|
||||||
|
const personData: PersonData = { ...data, role }
|
||||||
|
const dedupeKey = `${role}:${personData.name}:${personData.passportNumber}`
|
||||||
|
|
||||||
|
let poolId = poolByLegacyKey.get(dedupeKey)
|
||||||
|
if (!poolId) {
|
||||||
|
poolId = record.payloadId === 'skipper' ? 'skipper' : record.payloadId
|
||||||
|
const existing = await db.personPool.get(poolId)
|
||||||
|
if (!existing) {
|
||||||
|
const encrypted = await encryptJson(personData, masterKey)
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
await db.personPool.put({
|
||||||
|
payloadId: poolId,
|
||||||
|
encryptedData: encrypted.ciphertext,
|
||||||
|
iv: encrypted.iv,
|
||||||
|
tag: encrypted.tag,
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
await db.userSyncQueue.put({
|
||||||
|
action: 'create',
|
||||||
|
type: 'person',
|
||||||
|
payloadId: poolId,
|
||||||
|
data: JSON.stringify(encrypted),
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
}
|
||||||
|
poolByLegacyKey.set(dedupeKey, poolId)
|
||||||
|
poolData.set(poolId, personData)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === 'skipper') {
|
||||||
|
if (!legacyIds.skipperIds.includes(poolId)) legacyIds.skipperIds.push(poolId)
|
||||||
|
} else {
|
||||||
|
legacyIds.crewIds.push(poolId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeSkipperId = pickActiveSkipperId(legacyIds.skipperIds)
|
||||||
|
const existingSelection = await db.logbookCrewSelections.get(logbook.id)
|
||||||
|
if (!existingSelection && (activeSkipperId || legacyIds.crewIds.length > 0)) {
|
||||||
|
const selection = buildLogbookCrewSelection(
|
||||||
|
activeSkipperId,
|
||||||
|
legacyIds.crewIds,
|
||||||
|
poolData
|
||||||
|
)
|
||||||
|
await saveLogbookCrewSelection(logbook.id, selection)
|
||||||
|
|
||||||
|
const entryCrew = entryCrewFromLogbookSelection(selection)
|
||||||
|
const entries = await db.entries.where({ logbookId: logbook.id }).toArray()
|
||||||
|
for (const entry of entries) {
|
||||||
|
const dec = (await decryptJson(entry.encryptedData, entry.iv, entry.tag, logbookKey)) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
> | null
|
||||||
|
if (!dec) continue
|
||||||
|
if (dec.selectedSkipperId != null || (Array.isArray(dec.selectedCrewIds) && dec.selectedCrewIds.length > 0)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const updated = {
|
||||||
|
...dec,
|
||||||
|
...entryCrew
|
||||||
|
}
|
||||||
|
const encrypted = await encryptJson(updated, logbookKey)
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
await db.entries.put({
|
||||||
|
...entry,
|
||||||
|
encryptedData: encrypted.ciphertext,
|
||||||
|
iv: encrypted.iv,
|
||||||
|
tag: encrypted.tag,
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
await db.syncQueue.put({
|
||||||
|
action: 'update',
|
||||||
|
type: 'entry',
|
||||||
|
payloadId: entry.payloadId,
|
||||||
|
logbookId: logbook.id,
|
||||||
|
data: JSON.stringify(encrypted),
|
||||||
|
updatedAt: now
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(MIGRATION_FLAG, userId)
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Crew pool migration failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { decryptJson } from './crypto.js'
|
|||||||
import { formatSignatureForExport, normalizeSignature } from '../utils/signatures.js'
|
import { formatSignatureForExport, normalizeSignature } from '../utils/signatures.js'
|
||||||
import { sortLogEventsByTime } from '../utils/logEntryPayload.js'
|
import { sortLogEventsByTime } from '../utils/logEntryPayload.js'
|
||||||
import i18n from '../i18n/index.js'
|
import i18n from '../i18n/index.js'
|
||||||
|
import { formatAppDateTime } from '../utils/dateTimeFormat.js'
|
||||||
|
|
||||||
function escapeCsvValue(val: string | number | undefined | null): string {
|
function escapeCsvValue(val: string | number | undefined | null): string {
|
||||||
if (val === null || val === undefined) return '';
|
if (val === null || val === undefined) return '';
|
||||||
@@ -36,22 +37,17 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
|||||||
throw new Error('Encryption key not found. User must log in.')
|
throw new Error('Encryption key not found. User must log in.')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Fetch Yacht details
|
const { resolveVesselForLogbook } = await import('./resolveVessel.js')
|
||||||
const yachtRecord = await db.yachts.get(logbookId);
|
const yacht = await resolveVesselForLogbook(logbookId)
|
||||||
if (yachtRecord) {
|
if (yacht) {
|
||||||
try {
|
yachtName = yacht.name || ''
|
||||||
const yacht = await decryptJson(yachtRecord.encryptedData, yachtRecord.iv, yachtRecord.tag, masterKey);
|
homePort = yacht.homePort || ''
|
||||||
yachtName = yacht.name || '';
|
owner = yacht.owner || ''
|
||||||
homePort = yacht.port || '';
|
charter = yacht.charterCompany || ''
|
||||||
owner = yacht.owner || '';
|
registration = yacht.registrationNumber || ''
|
||||||
charter = yacht.charter || '';
|
callsign = yacht.callSign || ''
|
||||||
registration = yacht.registration || '';
|
atis = yacht.atis || ''
|
||||||
callsign = yacht.callsign || '';
|
mmsi = yacht.mmsi || ''
|
||||||
atis = yacht.atis || '';
|
|
||||||
mmsi = yacht.mmsi || '';
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to decrypt yacht details for CSV:', e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Fetch logbook entries
|
// 2. Fetch logbook entries
|
||||||
@@ -78,15 +74,16 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
|||||||
|
|
||||||
// Headers matching the requested event fields & metadata
|
// Headers matching the requested event fields & metadata
|
||||||
const headers = [
|
const headers = [
|
||||||
'Date', 'Day of Travel', 'Departure Port', 'Destination Port',
|
'Date', 'Day of Travel', 'Departure Port', 'Destination Port', 'AI Summary',
|
||||||
'Skipper Signature', 'Crew Signature',
|
'Skipper Signature', 'Crew Signature',
|
||||||
'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)', 'Motor Hours (h)',
|
'Track Distance (nm)', 'Track Max Speed (kn)', 'Track Avg Speed (kn)', 'Motor Hours (h)',
|
||||||
'Event Time', 'MgK Course', 'RwK Course',
|
'Event Time', 'Event Creator', 'MgK Course', 'RwK Course',
|
||||||
'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State',
|
'Wind Dir', 'Wind Str', 'Barometer (hPa)', 'Sea State', 'Visibility',
|
||||||
'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)',
|
'Current', 'Heel Angle', 'Sails/Motor', 'Log (nm)', 'Distance (nm)',
|
||||||
'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'
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -94,11 +91,11 @@ export async function exportLogbookToCsv(logbookId: string, preloadedData?: { ya
|
|||||||
const exportLabels = {
|
const exportLabels = {
|
||||||
imagePlaceholder: i18n.t('logs.sign_export_image'),
|
imagePlaceholder: i18n.t('logs.sign_export_image'),
|
||||||
passkeyLabel: (username: string, signedAt: string) => {
|
passkeyLabel: (username: string, signedAt: string) => {
|
||||||
const date = new Date(signedAt).toLocaleString(i18n.language === 'de' ? 'de-DE' : 'en-GB')
|
const date = formatAppDateTime(signedAt, i18n.language)
|
||||||
return i18n.t('logs.sign_passkey_export', { username, date })
|
return i18n.t('logs.sign_passkey_export', { username, date })
|
||||||
},
|
},
|
||||||
attributionLabel: (username: string, signedAt: string) => {
|
attributionLabel: (username: string, signedAt: string) => {
|
||||||
const date = new Date(signedAt).toLocaleString(i18n.language === 'de' ? 'de-DE' : 'en-GB')
|
const date = formatAppDateTime(signedAt, i18n.language)
|
||||||
return i18n.t('logs.sign_attribution_export', { username, date })
|
return i18n.t('logs.sign_attribution_export', { username, date })
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -122,36 +119,52 @@ 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 aiSummary = entry.aiSummary ?? '';
|
||||||
|
|
||||||
|
const crewSnapshots = (entry.crewSnapshotsById as Record<string, any>) || {};
|
||||||
const eventsList = entry.events || [];
|
const eventsList = entry.events || [];
|
||||||
if (eventsList.length === 0) {
|
if (eventsList.length === 0) {
|
||||||
// Create one row even if there are no events for the day
|
// Create one row even if there are no events for the day
|
||||||
rows.push([
|
rows.push([
|
||||||
dateVal, travelDay, dep, dest,
|
dateVal, travelDay, dep, dest, aiSummary,
|
||||||
signS, signC,
|
signS, signC,
|
||||||
trackDist, trackMax, trackAvg, motorH,
|
trackDist, trackMax, trackAvg, motorH,
|
||||||
'', '', '',
|
|
||||||
'', '', '', '',
|
'', '', '', '',
|
||||||
'', '', '', '', '',
|
'', '', '', '', '',
|
||||||
|
'', '', '', '', '',
|
||||||
'', '', '',
|
'', '', '',
|
||||||
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 {
|
||||||
// Sort events chronologically by time
|
// Sort events chronologically by time
|
||||||
const sortedEvents = sortLogEventsByTime(eventsList);
|
const sortedEvents = sortLogEventsByTime(eventsList);
|
||||||
for (const ev of sortedEvents) {
|
for (const ev of sortedEvents) {
|
||||||
|
const creatorSnap = ev.creatorId ? crewSnapshots[ev.creatorId] : null;
|
||||||
|
let creatorName = '';
|
||||||
|
if (creatorSnap) {
|
||||||
|
creatorName = creatorSnap.name || '';
|
||||||
|
} else if (ev.creatorId === 'skipper') {
|
||||||
|
creatorName = 'Skipper';
|
||||||
|
} else if (ev.creatorId) {
|
||||||
|
creatorName = ev.creatorId;
|
||||||
|
}
|
||||||
|
|
||||||
rows.push([
|
rows.push([
|
||||||
dateVal, travelDay, dep, dest,
|
dateVal, travelDay, dep, dest, aiSummary,
|
||||||
signS, signC,
|
signS, signC,
|
||||||
trackDist, trackMax, trackAvg, motorH,
|
trackDist, trackMax, trackAvg, motorH,
|
||||||
ev.time || '', ev.mgk || '', ev.rwk || '',
|
ev.time || '', creatorName, ev.mgk || '', ev.rwk || '',
|
||||||
ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '',
|
ev.windDirection || '', ev.windStrength || '', ev.windPressure || '', ev.seaState || '',
|
||||||
|
ev.visibility || '',
|
||||||
ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '',
|
ev.current || '', ev.heel || '', ev.sailsOrMotor || '', ev.logReading || '', ev.distance || '',
|
||||||
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));
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user